mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-06 13:11:43 -05:00
Compare commits
293 Commits
arnau/keys
...
better-di-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
281837b4c2 | ||
|
|
a30dc63714 | ||
|
|
93431ee6d5 | ||
|
|
31c4eb6380 | ||
|
|
6b6c340844 | ||
|
|
e1a58ef576 | ||
|
|
ec00cc6a3f | ||
|
|
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 |
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
|
||||
23
.github/dependabot.yml
vendored
23
.github/dependabot.yml
vendored
@@ -8,4 +8,25 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
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
|
||||
|
||||
110
.github/workflows/test-dev.yml
vendored
110
.github/workflows/test-dev.yml
vendored
@@ -9,74 +9,128 @@ 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
|
||||
./gradlew --dry-run core:virtualDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
needs: compile
|
||||
if: ${{ !cancelled() }} # 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
|
||||
|
||||
instrumented_tests:
|
||||
needs: compile
|
||||
if: ${{ !cancelled() }} # 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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
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 subproject (manages its own dependencies itself, however from same version catalog)
|
||||
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)
|
||||
}
|
||||
29
app-ose/src/main/AndroidManifest.xml
Normal file
29
app-ose/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<application android:name=".App">
|
||||
|
||||
<!-- Required for Hilt/WorkManager integration. See
|
||||
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
|
||||
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
|
||||
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
30
app-ose/src/main/kotlin/com/davx5/ose/App.kt
Normal file
30
app-ose/src/main/kotlin/com/davx5/ose/App.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose
|
||||
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.CoreApp
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
/**
|
||||
* Actual implementation of Application, used for Hilt. Delegates to [CoreApp].
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class App: CoreApp(), Configuration.Provider {
|
||||
|
||||
/**
|
||||
* Required for Hilt/WorkManager integration, see:
|
||||
* https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
|
||||
*
|
||||
* This requires to remove the androidx.work.WorkManagerInitializer from App Startup
|
||||
* in the AndroidManifest, see:
|
||||
* https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
|
||||
*/
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
}
|
||||
@@ -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,19 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import at.bitfire.davdroid.ui.AccountsDrawerHandler
|
||||
import at.bitfire.davdroid.ui.OseAccountsDrawerHandler
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityComponent::class)
|
||||
interface AccountsDrawerHandlerModule {
|
||||
@Binds
|
||||
fun accountsDrawerHandler(impl: OseAccountsDrawerHandler): AccountsDrawerHandler
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import at.bitfire.davdroid.ui.about.AboutActivity
|
||||
import com.davx5.ose.ui.about.OpenSourceLicenseInfoProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
interface AppLicenseInfoProviderModule {
|
||||
@Binds
|
||||
fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider
|
||||
}
|
||||
@@ -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 Cert4AndroidModule {
|
||||
|
||||
@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))
|
||||
|
||||
}
|
||||
@@ -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.qualifier.DarkColorScheme
|
||||
import at.bitfire.davdroid.di.qualifier.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,19 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import at.bitfire.davdroid.ui.intro.IntroPageFactory
|
||||
import com.davx5.ose.ui.intro.OseIntroPageFactory
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Global {
|
||||
@Binds
|
||||
fun introPageFactory(impl: OseIntroPageFactory): IntroPageFactory
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
|
||||
import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LoginTypesProviderModule {
|
||||
|
||||
@Binds
|
||||
fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider
|
||||
|
||||
}
|
||||
@@ -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.qualifier.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,11 +2,19 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.intro
|
||||
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
|
||||
import at.bitfire.davdroid.ui.intro.PermissionsIntroPage
|
||||
import at.bitfire.davdroid.ui.intro.TasksIntroPage
|
||||
import at.bitfire.davdroid.ui.intro.WelcomePage
|
||||
import javax.inject.Inject
|
||||
|
||||
class OseIntroPageFactory @Inject constructor(
|
||||
backupsPage: BackupsPage,
|
||||
batteryOptimizationsPage: BatteryOptimizationsPage,
|
||||
openSourcePage: OpenSourcePage,
|
||||
permissionsIntroPage: PermissionsIntroPage,
|
||||
@@ -18,6 +26,7 @@ class OseIntroPageFactory @Inject constructor(
|
||||
tasksIntroPage,
|
||||
permissionsIntroPage,
|
||||
batteryOptimizationsPage,
|
||||
backupsPage,
|
||||
openSourcePage
|
||||
)
|
||||
|
||||
1
app/src/.gitignore
vendored
1
app/src/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
espressoTest
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Use this custom rule to ignore exceptions thrown by another rule.
|
||||
*
|
||||
* @param innerRule The rule to wrap.
|
||||
* @param exceptionsToIgnore The exceptions to ignore.
|
||||
*/
|
||||
class CatchExceptionsRule(
|
||||
private val innerRule: TestRule,
|
||||
private vararg val exceptionsToIgnore: KClass<out Throwable>
|
||||
) : TestRule {
|
||||
override fun apply(base: Statement, description: Description): Statement {
|
||||
return object : Statement() {
|
||||
override fun evaluate() {
|
||||
try {
|
||||
innerRule.apply(base, description).evaluate()
|
||||
} catch (e: Throwable) {
|
||||
val shouldIgnore = exceptionsToIgnore.any { it.isInstance(e) }
|
||||
if (shouldIgnore)
|
||||
base.evaluate()
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,33 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.log.LogcatHandler
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Module that provides verbose logging for tests.
|
||||
*/
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [LoggerModule::class]
|
||||
)
|
||||
@Module
|
||||
class TestLoggerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun logger(): Logger = Logger.getGlobal().apply {
|
||||
level = Level.ALL
|
||||
addHandler(LogcatHandler())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,74 +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 {
|
||||
|
||||
@Inject
|
||||
lateinit var repository: DavHomeSetRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
var serviceId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
serviceId = serviceRepository.insertOrReplaceBlocking(
|
||||
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.copy(id = 1L), repository.getByIdBlocking(1L))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = repository.insertOrUpdateByUrlBlocking(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.copy(id = 1L), repository.getByIdBlocking(1L))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = repository.insertOrUpdateByUrlBlocking(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.copy(id = 2L), repository.getByIdBlocking(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteBlocking() {
|
||||
// should delete row with given primary key (id)
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, repository.getByIdBlocking(1L))
|
||||
|
||||
repository.deleteBlocking(entry1)
|
||||
assertEquals(null, repository.getByIdBlocking(1L))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,749 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
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.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionListRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
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() = runTest {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
// 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() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
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,58 +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_PUSH = "webdav_push.html"
|
||||
|
||||
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,47 +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.security.KeyChain
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.net.Socket
|
||||
import java.security.Principal
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
|
||||
/**
|
||||
* KeyManager that provides a client certificate and private key from the Android KeyChain.
|
||||
*
|
||||
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
|
||||
*/
|
||||
class ClientCertKeyManager @AssistedInject constructor(
|
||||
@Assisted private val alias: String,
|
||||
@ApplicationContext private val context: Context
|
||||
): X509ExtendedKeyManager() {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(alias: String): ClientCertKeyManager
|
||||
}
|
||||
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
|
||||
|
||||
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 }
|
||||
|
||||
}
|
||||
@@ -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,301 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Cache
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
|
||||
class HttpClient(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val authorizationService: AuthorizationService? = null
|
||||
): AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
authorizationService?.dispose()
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
|
||||
|
||||
// builder
|
||||
|
||||
/**
|
||||
* Builder for the [HttpClient].
|
||||
*
|
||||
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
|
||||
* there's only one [Builder] object and setting properties from one location would influence the others.
|
||||
*
|
||||
* To generate multiple clients, inject and use `Provider<HttpClient.Builder>` instead.
|
||||
*/
|
||||
class Builder @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val authorizationServiceProvider: Provider<AuthorizationService>,
|
||||
@ApplicationContext private val context: Context,
|
||||
defaultLogger: Logger,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val keyManagerFactory: ClientCertKeyManager.Factory,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
// property setters/getters
|
||||
|
||||
private var logger: Logger = defaultLogger
|
||||
fun setLogger(logger: Logger): Builder {
|
||||
this.logger = logger
|
||||
return this
|
||||
}
|
||||
|
||||
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder {
|
||||
loggerInterceptorLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar = MemoryCookieStore()
|
||||
fun setCookieStore(cookieStore: CookieJar): Builder {
|
||||
this.cookieStore = cookieStore
|
||||
return this
|
||||
}
|
||||
|
||||
private var authenticationInterceptor: Interceptor? = null
|
||||
private var authenticator: Authenticator? = null
|
||||
private var authorizationService: AuthorizationService? = null
|
||||
private var certificateAlias: String? = null
|
||||
fun authenticate(host: String?, credentials: Credentials, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
|
||||
if (credentials.authState != null) {
|
||||
// OAuth
|
||||
val authService = authorizationServiceProvider.get()
|
||||
authenticationInterceptor = BearerAuthInterceptor.fromAuthState(authService, credentials.authState, authStateCallback)
|
||||
authorizationService = authService
|
||||
|
||||
} else if (credentials.username != null && credentials.password != null) {
|
||||
// basic/digest auth
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive = true)
|
||||
authenticationInterceptor = authHandler
|
||||
authenticator = authHandler
|
||||
}
|
||||
|
||||
// client certificate
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private var followRedirects = false
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
followRedirects = follow
|
||||
return this
|
||||
}
|
||||
|
||||
private var cache: Cache? = null
|
||||
@Suppress("unused")
|
||||
fun withDiskCache(maxSize: Long = 10*1024*1024): 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")
|
||||
cache = Cache(cacheDir, maxSize)
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
// convenience builders from other classes
|
||||
|
||||
/**
|
||||
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
|
||||
*
|
||||
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
|
||||
*
|
||||
* @param account the account to take authentication from
|
||||
* @param onlyHost if set: only authenticate for this host name
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
@WorkerThread
|
||||
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
authenticate(
|
||||
host = onlyHost,
|
||||
credentials = accountSettings.credentials(),
|
||||
authStateCallback = { authState: AuthState ->
|
||||
accountSettings.credentials(Credentials(authState = authState))
|
||||
}
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [fromAccount], but can be called on any thread.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
|
||||
fromAccount(account, onlyHost)
|
||||
}
|
||||
|
||||
|
||||
// actual builder
|
||||
|
||||
fun build(): HttpClient {
|
||||
val okBuilder = 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
|
||||
|
||||
// don't allow redirects by default because it would break PROPFIND handling
|
||||
.followRedirects(followRedirects)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addInterceptor(UserAgentInterceptor)
|
||||
|
||||
// connection-private cookie store
|
||||
.cookieJar(cookieStore)
|
||||
|
||||
// allow cleartext and TLS 1.2+
|
||||
.connectionSpecs(listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.MODERN_TLS
|
||||
))
|
||||
|
||||
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
||||
.addInterceptor(BrotliInterceptor)
|
||||
|
||||
// add cache, if requested
|
||||
.cache(cache)
|
||||
|
||||
// app-wide custom proxy support
|
||||
buildProxy(okBuilder)
|
||||
|
||||
// add authentication
|
||||
buildAuthentication(okBuilder)
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.level = loggerInterceptorLevel
|
||||
okBuilder.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
return HttpClient(
|
||||
okHttpClient = okBuilder.build(),
|
||||
authorizationService = authorizationService
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
||||
// basic/digest auth and OAuth
|
||||
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
|
||||
authenticator?.let { okBuilder.authenticator(it) }
|
||||
|
||||
// client certificate
|
||||
val keyManager: KeyManager? = certificateAlias?.let { alias ->
|
||||
try {
|
||||
val manager = keyManagerFactory.create(alias)
|
||||
logger.fine("Using certificate $alias for authentication")
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
|
||||
|
||||
manager
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// cert4android integration
|
||||
val certManager = CustomCertManager(
|
||||
context = context,
|
||||
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
|
||||
appInForeground = if (/* davx5-ose */ true)
|
||||
ForegroundTracker.inForeground // interactive mode
|
||||
else
|
||||
null // non-interactive mode
|
||||
)
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
/* km = */ if (keyManager != null) arrayOf(keyManager) else null,
|
||||
/* tm = */ arrayOf(certManager),
|
||||
/* random = */ null
|
||||
)
|
||||
okBuilder
|
||||
.sslSocketFactory(sslContext.socketFactory, certManager)
|
||||
.hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier))
|
||||
}
|
||||
|
||||
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
|
||||
try {
|
||||
val proxyTypeValue = settingsManager.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(
|
||||
settingsManager.getString(Settings.PROXY_HOST),
|
||||
settingsManager.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")
|
||||
}
|
||||
okBuilder.proxy(proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
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
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Implements Nextcloud Login Flow v2.
|
||||
*
|
||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
*/
|
||||
class NextcloudLoginFlow @Inject constructor(
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): 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 = httpClientBuilder
|
||||
.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,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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.text.Collator
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
suspend 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._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
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 dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
|
||||
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,269 +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.davdroid.resource.LocalEvent.Companion.numInstances
|
||||
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.ical4jVersion
|
||||
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/" + 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] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
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 dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
|
||||
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.insertOrUpdateByUrlBlocking(
|
||||
// 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.getByServiceBlocking(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.insertOrUpdateByUrlBlocking(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.deleteBlocking(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 personal home-sets
|
||||
* @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,220 +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.BuildConfig
|
||||
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.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
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.concurrent.atomic.AtomicBoolean
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
/**
|
||||
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
|
||||
* create a [SyncAdapterService] instance before Hilt is initialized during the tests.
|
||||
*/
|
||||
@dagger.hilt.EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface EntryPoint {
|
||||
fun syncAdapter(): SyncAdapter
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
if (BuildConfig.DEBUG && !syncActive.get()) {
|
||||
// only for debug builds/testing: syncActive flag
|
||||
val logger = Logger.getLogger(this@SyncAdapterService::class.java.name)
|
||||
logger.log(Level.WARNING, "SyncAdapterService.onBind() was called but syncActive = false. Ignoring")
|
||||
|
||||
val fakeAdapter = object: AbstractThreadedSyncAdapter(this, false) {
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val message = StringBuilder()
|
||||
message.append("FakeSyncAdapter onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)")
|
||||
for (key in extras.keySet())
|
||||
message.append("\n\textras[$key] = ${extras[key]}")
|
||||
logger.warning(message.toString())
|
||||
}
|
||||
}
|
||||
return fakeAdapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
// create sync adapter via Hilt
|
||||
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(this)
|
||||
val syncAdapter = entryPoint.syncAdapter()
|
||||
return syncAdapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Flag to indicate whether the sync adapter should be active. When it is `false`, synchronization will not be run
|
||||
* (only intended for tests).
|
||||
*/
|
||||
val syncActive = AtomicBoolean(true)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 = */ context,
|
||||
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
|
||||
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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.getBlocking(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), fromUpload = 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
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
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,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,355 +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.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.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.di.IoDispatcher
|
||||
import at.bitfire.davdroid.ui.composable.PixelBoxes
|
||||
import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
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.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.Collator
|
||||
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.collectAsStateWithLifecycle(emptyList())
|
||||
TranslatorsGallery(translations.value)
|
||||
}
|
||||
|
||||
2 -> LibrariesContainer(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
padding = LibraryDefaults.libraryPadding(
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
),
|
||||
dimensions = LibraryDefaults.libraryDimensions(
|
||||
itemSpacing = 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class Model @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
): ViewModel() {
|
||||
|
||||
data class Translation(
|
||||
val language: String,
|
||||
val translators: Set<String>
|
||||
)
|
||||
|
||||
val translations: Flow<List<Translation>> = flow {
|
||||
val translations = loadTranslations()
|
||||
emit(translations)
|
||||
}
|
||||
|
||||
private suspend fun loadTranslations(): List<Translation> = withContext(ioDispatcher) {
|
||||
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)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't load translators", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
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,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 IconSyncButtonWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = IconSyncButtonWidget()
|
||||
}
|
||||
@@ -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 LabeledSyncButtonWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = LabeledSyncButtonWidget()
|
||||
}
|
||||
@@ -1,204 +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.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.annotation.StringDef
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.security.Key
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.inject.Inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class CredentialsStore @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) {
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "credentials")
|
||||
private val dataStore by lazy { context.dataStore }
|
||||
|
||||
val ks: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
||||
|
||||
/*
|
||||
* Generate a new EC key pair entry in the Android Keystore by
|
||||
* using the KeyPairGenerator API. The private key can only be
|
||||
* used for signing or verification and only with SHA-256 or
|
||||
* SHA-512 as the message digest.
|
||||
*/
|
||||
private fun generateSecretKey(alias: String): SecretKey {
|
||||
val keyEntry = ks.getEntry(alias, null)
|
||||
if (keyEntry == null) {
|
||||
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||
val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
).run {
|
||||
setBlockModes(KeyProperties.BLOCK_MODE_CBC)
|
||||
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
build()
|
||||
}
|
||||
kg.init(parameterSpec)
|
||||
|
||||
ks.setKeyEntry(alias, kg.generateKey(), null, null)
|
||||
}
|
||||
|
||||
return getSecretKey(alias) ?: error("There was an error while generating the key")
|
||||
}
|
||||
|
||||
private fun getSecretKey(alias: String): SecretKey? {
|
||||
return ks.getKey(alias, null) as SecretKey?
|
||||
}
|
||||
|
||||
private fun encrypt(key: Key, data: String): Pair<ByteArray, ByteArray> {
|
||||
return Cipher.getInstance(AES_MODE).apply {
|
||||
init(Cipher.ENCRYPT_MODE, key)
|
||||
}.let { it.doFinal(data.encodeToByteArray()) to it.iv }
|
||||
}
|
||||
|
||||
private fun decrypt(key: Key, encryptedData: ByteArray, iv: ByteArray): String {
|
||||
return Cipher.getInstance(AES_MODE).apply {
|
||||
init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
}.doFinal(encryptedData).decodeToString()
|
||||
}
|
||||
|
||||
suspend fun getCredentials(mountId: Long): Credentials? {
|
||||
val hasCredentialsKey = preferenceKey<Boolean>(mountId, HAS_CREDENTIALS)
|
||||
val userNameKey = preferenceKey<String>(mountId, USER_NAME)
|
||||
val passwordKey = preferenceKey<String>(mountId, PASSWORD)
|
||||
val cerAliasKey = preferenceKey<String>(mountId, CERTIFICATE_ALIAS)
|
||||
|
||||
val data = dataStore.data.first()
|
||||
if (data[hasCredentialsKey] != true) return null
|
||||
|
||||
val key = getSecretKey(mountId.toString())
|
||||
checkNotNull(key) { "Could not find any key for mount $mountId" }
|
||||
|
||||
val username = data.getWithIV(userNameKey)?.let { (value, iv) -> decrypt(key, value.encodeToByteArray(), iv) }
|
||||
val password = data.getWithIV(passwordKey)?.let { (value, iv) -> decrypt(key, value.encodeToByteArray(), iv) }
|
||||
val cerAlias = data.getWithIV(cerAliasKey)?.let { (value, iv) -> decrypt(key, value.encodeToByteArray(), iv) }
|
||||
|
||||
return Credentials(username, password, cerAlias)
|
||||
}
|
||||
|
||||
suspend fun setCredentials(mountId: Long, credentials: Credentials?) {
|
||||
val alias = mountId.toString()
|
||||
val key = getSecretKey(alias) ?: generateSecretKey(alias)
|
||||
|
||||
dataStore.edit { pref ->
|
||||
val hasCredentials = preferenceKey<Boolean>(mountId, HAS_CREDENTIALS)
|
||||
val userNameKey = preferenceKey<String>(mountId, USER_NAME)
|
||||
val passwordKey = preferenceKey<String>(mountId, PASSWORD)
|
||||
val cerAliasKey = preferenceKey<String>(mountId, CERTIFICATE_ALIAS)
|
||||
|
||||
if (credentials == null) {
|
||||
pref.remove(hasCredentials)
|
||||
pref.remove(userNameKey)
|
||||
pref.remove(userNameKey.iv())
|
||||
pref.remove(passwordKey)
|
||||
pref.remove(passwordKey.iv())
|
||||
pref.remove(cerAliasKey)
|
||||
pref.remove(cerAliasKey.iv())
|
||||
} else {
|
||||
check(credentials.username != null || credentials.password != null || credentials.certificateAlias != null) {
|
||||
"Credentials given are all-null"
|
||||
}
|
||||
|
||||
credentials.username?.let { username ->
|
||||
val (data, iv) = encrypt(key, username)
|
||||
pref.setWithIV(userNameKey, data.decodeToString(), iv)
|
||||
}
|
||||
credentials.password?.let { password ->
|
||||
val (data, iv) = encrypt(key, password)
|
||||
pref.setWithIV(passwordKey, data.decodeToString(), iv)
|
||||
}
|
||||
credentials.certificateAlias?.let { certificateAlias ->
|
||||
val (data, iv) = encrypt(key, certificateAlias)
|
||||
pref.setWithIV(cerAliasKey, data.decodeToString(), iv)
|
||||
}
|
||||
|
||||
pref[hasCredentials] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <Type : Any> preferenceKeyFromGeneric(keyName: String, type: KClass<Type>): Preferences.Key<Type> {
|
||||
val key = when (type) {
|
||||
String::class -> stringPreferencesKey(keyName)
|
||||
Boolean::class -> booleanPreferencesKey(keyName)
|
||||
else -> error("Got unsupported type ${type.simpleName}")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return key as Preferences.Key<Type>
|
||||
}
|
||||
|
||||
private fun <Type : Any> preferenceKey(
|
||||
mountId: Long,
|
||||
@KeyName name: String,
|
||||
suffix: String = "",
|
||||
type: KClass<Type>,
|
||||
): Preferences.Key<Type> {
|
||||
val keyName = "$mountId.$name$suffix"
|
||||
return preferenceKeyFromGeneric(keyName, type)
|
||||
}
|
||||
|
||||
private inline fun <reified Type : Any> preferenceKey(
|
||||
mountId: Long,
|
||||
@KeyName name: String,
|
||||
suffix: String = "",
|
||||
): Preferences.Key<Type> = preferenceKey(mountId, name, suffix, Type::class)
|
||||
|
||||
private fun <Type : Any> Preferences.Key<Type>.iv(): Preferences.Key<String> {
|
||||
return preferenceKeyFromGeneric("$name.iv", String::class)
|
||||
}
|
||||
|
||||
private inline fun <reified Type : Any> MutablePreferences.setWithIV(key: Preferences.Key<Type>, value: Type, iv: ByteArray) {
|
||||
set(key, value)
|
||||
set(key.iv(), iv.decodeToString())
|
||||
}
|
||||
|
||||
private inline fun <reified Type : Any> Preferences.getWithIV(key: Preferences.Key<Type>): Pair<Type, ByteArray>? {
|
||||
val value = get(key)
|
||||
val iv = get(key.iv())?.encodeToByteArray()
|
||||
return if (value != null && iv != null) value to iv
|
||||
else null
|
||||
}
|
||||
|
||||
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@StringDef(
|
||||
HAS_CREDENTIALS,
|
||||
USER_NAME,
|
||||
PASSWORD,
|
||||
CERTIFICATE_ALIAS
|
||||
)
|
||||
annotation class KeyName
|
||||
|
||||
companion object {
|
||||
const val HAS_CREDENTIALS = "has_credentials"
|
||||
const val USER_NAME = "user_name"
|
||||
const val PASSWORD = "password"
|
||||
const val CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
|
||||
const val AES_MODE = KeyProperties.KEY_ALGORITHM_AES + "/" + KeyProperties.BLOCK_MODE_CBC + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,821 +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.core.app.TaskStackBuilder
|
||||
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.db.WebDavDocumentDao
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.MemoryCookieStore
|
||||
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
|
||||
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.EntryPoints
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Provides functionality on WebDav documents.
|
||||
*
|
||||
* Actual implementation should go into [DavDocumentsActor].
|
||||
*/
|
||||
class DavDocumentsProvider(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
): DocumentsProvider() {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DavDocumentsProviderEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
fun davDocumentsActorFactory(): DavDocumentsActor.Factory
|
||||
fun documentSortByMapper(): DocumentSortByMapper
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val documentProviderScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
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 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 credentialsStore by lazy { webdavEntryPoint.credentialsStore() }
|
||||
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
|
||||
private val actor by lazy { globalEntryPoint.davDocumentsActorFactory().create(cookieStore, credentialsStore) }
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun shutdown() {
|
||||
documentProviderScope.cancel()
|
||||
}
|
||||
|
||||
|
||||
/*** 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
|
||||
))
|
||||
|
||||
runBlocking {
|
||||
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 = runBlocking { 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) {
|
||||
documentProviderScope.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)
|
||||
|
||||
// Prepare SORT BY clause
|
||||
val mapper = globalEntryPoint.documentSortByMapper()
|
||||
val sqlSortBy = if (sortOrder != null)
|
||||
mapper.mapContentProviderToSql(sortOrder)
|
||||
else
|
||||
WebDavDocumentDao.DEFAULT_ORDER
|
||||
|
||||
// Regardless of whether the worker is done, return the children we already have
|
||||
val children = documentDao.getChildren(parentId, sqlSortBy)
|
||||
for (child in children) {
|
||||
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 = runBlocking {
|
||||
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")
|
||||
|
||||
actor.httpClient(srcDoc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
|
||||
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(name)
|
||||
.build()
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.copy(dstUrl, false) {
|
||||
// successfully copied
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
|
||||
val 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)
|
||||
|
||||
/* return */ dstDocId
|
||||
}
|
||||
}
|
||||
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
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 parentUrl = parent.toHttpUrl(db)
|
||||
val newLocation = parentUrl.newBuilder()
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
val doc = DavResource(client.okHttpClient, newLocation)
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
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)
|
||||
|
||||
return@runBlocking docId.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(ignorePreconditionFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) = runBlocking {
|
||||
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 {
|
||||
runInterruptible(ioDispatcher) {
|
||||
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 = runBlocking {
|
||||
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 {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully moved
|
||||
}
|
||||
}
|
||||
|
||||
documentDao.update(doc.copy(parentId = dstParent.id))
|
||||
|
||||
actor.notifyFolderChanged(sourceParentDocumentId)
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
|
||||
doc.id.toString()
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV renameDocument $documentId $displayName")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val oldUrl = doc.toHttpUrl(db)
|
||||
val newLocation = oldUrl.newBuilder()
|
||||
.removePathSegment(oldUrl.pathSegments.lastIndex)
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
try {
|
||||
val dav = DavResource(client.okHttpClient, oldUrl)
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully renamed
|
||||
}
|
||||
}
|
||||
documentDao.update(doc.copy(name = newName))
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
|
||||
return@runBlocking doc.id.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
|
||||
HeadResponse.fromUrl(client, url)
|
||||
}
|
||||
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
|
||||
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 accessScope = CoroutineScope(SupervisorJob())
|
||||
signal?.setOnCancelListener {
|
||||
logger.fine("Cancelling WebDAV access to $url")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val fileInfo = accessScope.async {
|
||||
headRequest(client, url)
|
||||
}.await()
|
||||
logger.fine("Received file info: $fileInfo")
|
||||
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return@runBlocking 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, accessScope)
|
||||
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, accessScope) { 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) {
|
||||
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
|
||||
return null
|
||||
}
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal.setOnCancelListener {
|
||||
logger.fine("Cancelling thumbnail generation for $documentId")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
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 = accessScope.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(ioDispatcher) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 @AssistedInject constructor(
|
||||
@Assisted private val cookieStores: MutableMap<Long, CookieJar>,
|
||||
@Assisted private val credentialsStore: CredentialsStore,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(cookieStore: MutableMap<Long, CookieJar>, credentialsStore: CredentialsStore): DavDocumentsActor
|
||||
}
|
||||
|
||||
private val authority = context.getString(R.string.webdav_authority)
|
||||
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
|
||||
*/
|
||||
internal suspend 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>()
|
||||
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
httpClient(parent.mountId).use { client ->
|
||||
val folder = DavCollection(client.okHttpClient, parentUrl)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
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 suspend fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient = withContext(Dispatchers.IO) {
|
||||
val builder = httpClientBuilder.get()
|
||||
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
|
||||
.setCookieStore(
|
||||
cookieStores.getOrPut(mountId) { MemoryCookieStore() }
|
||||
)
|
||||
|
||||
credentialsStore.getCredentials(mountId)?.let { credentials ->
|
||||
builder.authenticate(host = null, credentials = credentials)
|
||||
}
|
||||
|
||||
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,
|
||||
TaskStackBuilder.create(ourContext)
|
||||
.addNextIntentWithParentStack(intent)
|
||||
.getPendingIntent(0, 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,186 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.os.Build
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
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 private val httpClient: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted private val headResponse: HeadResponse,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
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, externalScope: CoroutineScope): 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, externalScope)
|
||||
}
|
||||
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,203 +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.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.di.IoDispatcher
|
||||
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.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
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 private val client: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
@Assisted private val finishedCallback: OnSuccessCallback,
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
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?, externalScope: CoroutineScope, 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()
|
||||
|
||||
externalScope.launch(ioDispatcher) {
|
||||
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 necessarily an error)", e)
|
||||
writeFd.closeWithError(e.message)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
|
||||
try {
|
||||
readFd.close()
|
||||
writeFd.close()
|
||||
} catch (_: IOException) {}
|
||||
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
|
||||
finishedCallback.onSuccess(transferred)
|
||||
}
|
||||
|
||||
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,464 +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="dialog_enable">Võta kasutusele</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 uuesti</string>
|
||||
<plurals name="intro_open_source_dont_show_months">
|
||||
<item quantity="one">%d kuu jooksul</item>
|
||||
<item quantity="other">%d kuu jooksul</item>
|
||||
</plurals>
|
||||
<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_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>
|
||||
<!--Sync warnings-->
|
||||
<string name="sync_warning_no_notification_permission">Teavitused on välja lülitatud ja seega sünkroniseerimisvigade infot sa ei näe.</string>
|
||||
<string name="sync_warning_no_internet">Automaatne sünkroniseerimine pole aktiivne (kontrollitud internetiühendus puudub)</string>
|
||||
<string name="sync_warning_manage_connections">Halda ühendusi</string>
|
||||
<string name="sync_warning_datasaver_enabled">Andmemahu piiraja on kasutusel. Taustal sünkroniseerimine võib toimida piirangutega.</string>
|
||||
<string name="sync_warning_manage_datasaver">Halda andmemahu piirajat</string>
|
||||
<string name="sync_warning_battery_saver_enabled">Akukasutuse piiraja on kasutusel. Taustal sünkroniseerimine võib toimida piirangutega.</string>
|
||||
<string name="sync_warning_manage_battery_saver">Halda akukasutuse piirajat</string>
|
||||
<string name="sync_warning_low_storage">Vaba andmeruumi napib. Android ei sünkroniseeri kohalikke muudatusi kohe, vaid järgmise regulaarse sünkroniseerimise ajal.</string>
|
||||
<string name="sync_warning_manage_storage">Halda andmeruumi</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_title">Kalendri teenusepakkuja puudub. </string>
|
||||
<string name="sync_warning_calendar_storage_disabled_description">Kas sa oled lülitanud välja süsteemse kalendri salvestusruumi rakenduse „Calendar storage“ välja?</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_title">Kontaktide teenusepakkuja puudub.</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_description">Kas sa oled lülitanud välja süsteemse kontaktide salvestusruumi rakenduse „Contacts storage“ välja?</string>
|
||||
<string name="sync_warning_manage_apps">Halda rakendusi</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_distrust_system_certs_dialog_message">Kui see seadistus on aktiivne, siis operatsioonisüsteemis leiduvad sertifikaate ei loeta usaldusväärseteks. See tähendab, et iga kord pead sertifikaadiga käsitsi nõustuma (seda ka siis, kui server uuendab oma sertifikaate), vastasel juhul kasutajakonto seadistamine ja sünkroniseerimine ei toimi.</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>
|
||||
<string name="app_settings_unifiedpush_distributor_fcm">FCM (Google Play)</string>
|
||||
<string name="app_settings_unifiedpush_encrypted">Tõuketeavituste sõnumid on alati krüptitud.</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 juhendist <a href="%1$s">kuidas WebDAVi haakepunktid toimivad</a>.]]></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="dialog_enable">Attiva</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>
|
||||
<!--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="accounts_sync_all">Sincronizzazione di tutti gli account</string>
|
||||
<!--Sync warnings-->
|
||||
<string name="sync_warning_no_notification_permission">Notifiche non attive. Non sarai avvisato di eventuali errori di sincronizzazione</string>
|
||||
<string name="sync_warning_manage_connections">Gestione connessioni</string>
|
||||
<string name="sync_warning_datasaver_enabled">Risparmio dati attivo. La sincronizzazione in background è limitata,</string>
|
||||
<string name="sync_warning_battery_saver_enabled">Risparmio energetico attivo. La sincronizzazione in background è limitata,</string>
|
||||
<string name="sync_warning_manage_battery_saver">Gestisci risparmio energetico</string>
|
||||
<string name="sync_warning_low_storage">Spazio di memorizzazione scarso. Androin non salverà immediatamente i cambiamente, ma alla prossima sincronizzazione programmata.</string>
|
||||
<string name="sync_warning_manage_storage">Gestisci spazio di memorizzazione</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_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,426 +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>
|
||||
<!--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="accounts_sync_all">ყველა ანგარიშის სინქრონიზაცია</string>
|
||||
<!--Sync warnings-->
|
||||
<string name="sync_warning_no_notification_permission">შეტყობინებები გათიშული. თქვენ არ მიიღებთ შეტყობინებებს სიქნრონიზაციის შეცდომების შესახებ.</string>
|
||||
<string name="sync_warning_manage_connections">კავშირების მართვა</string>
|
||||
<string name="sync_warning_datasaver_enabled">გააქტიურებულია მონაცემთა შემნახველი. ფონური სინქრონიზაცია შეზღუდულია.</string>
|
||||
<string name="sync_warning_manage_datasaver">მონაცემთა შემნახველის მართვა</string>
|
||||
<string name="sync_warning_battery_saver_enabled">გფააქტიურებულია კვების ელემენტის შემნახველი. სინქრონიზაცია შეიძლება შეზღუდულ იქნას.</string>
|
||||
<string name="sync_warning_manage_battery_saver">კვების ელემენტის შემნახველის მართვა</string>
|
||||
<string name="sync_warning_low_storage">მეხსიერება ცოტა დარჩა. Android არ დაასინქრონიზირებს ადგილობრივ ცვლილებებს დაუყონებლივ, ხოლო დაასინქრონიზირებს შემდეგი რეგულარული სინქრონიზაციის დროს.</string>
|
||||
<string name="sync_warning_manage_storage">მეხსიერების მართვა</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_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,462 +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="dialog_enable">Inschakelen</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">Herinner me er niet aan voor</string>
|
||||
<plurals name="intro_open_source_dont_show_months">
|
||||
<item quantity="one">%d maand</item>
|
||||
<item quantity="other">%d maanden</item>
|
||||
</plurals>
|
||||
<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_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>
|
||||
<!--Sync warnings-->
|
||||
<string name="sync_warning_no_notification_permission">Meldingen uitgeschakeld. U krijgt geen meldingen over synchronisatiefouten.</string>
|
||||
<string name="sync_warning_no_internet">Automatische synchronisatie niet actief (geen geverifieerde internetverbinding).</string>
|
||||
<string name="sync_warning_manage_connections">Verbindingen beheren</string>
|
||||
<string name="sync_warning_datasaver_enabled">Gegevensbesparing ingeschakeld. Synchronisatie op de achtergrond is beperkt.</string>
|
||||
<string name="sync_warning_manage_datasaver">Beheer van gegevensbesparing</string>
|
||||
<string name="sync_warning_battery_saver_enabled">Batterijbesparing ingeschakeld. Synchronisatie kan beperkt zijn.</string>
|
||||
<string name="sync_warning_manage_battery_saver">Batterijbesparing beheren</string>
|
||||
<string name="sync_warning_low_storage">Weinig opslagruimte. Android zal lokale wijzigingen niet onmiddellijk synchroniseren, maar tijdens de volgende reguliere synchronisatie.</string>
|
||||
<string name="sync_warning_manage_storage">Opslag beheren</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_title">Aanbieder voor Kalender ontbreekt</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_description">Heb je de systeemapp \"Kalenderopslag\" uitgeschakeld?</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_title">Aanbieder voor Contactpersonen ontbreekt</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_description">Heb je de systeemapp \"Contactenopslag\" uitgeschakeld?</string>
|
||||
<string name="sync_warning_manage_apps">Apps beheren</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_distrust_system_certs_dialog_message">Als deze instelling actief is, worden systeemcertificaten niet als betrouwbaar beschouwd. Dit betekent dat je elk certificaat handmatig moet accepteren (ook wanneer de server zijn certificaat vernieuwt) anders werken accountinstelling en synchronisatie niet.</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 voor <a href="%1$s">hoe WebDAV-mounts werken</a>.]]></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,465 +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="account_prefs_use_app">Nu schimba contul aici! Utilizează direct aplicația pentru a gestiona conturile în schimb.</string>
|
||||
<string name="dialog_delete">Șterge</string>
|
||||
<string name="dialog_remove">Elimină</string>
|
||||
<string name="dialog_deny">Anulează</string>
|
||||
<string name="dialog_enable">Activează</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-mi aminti</string>
|
||||
<plurals name="intro_open_source_dont_show_months">
|
||||
<item quantity="one">%d lună</item>
|
||||
<item quantity="few">%d luni</item>
|
||||
<item quantity="other">%d luni</item>
|
||||
</plurals>
|
||||
<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_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>
|
||||
<!--Sync warnings-->
|
||||
<string name="sync_warning_no_notification_permission">Notificări dezactivate. Nu vei fi notificat despre erorile de sincronizare.</string>
|
||||
<string name="sync_warning_no_internet">Sincronizarea automată nu este activă (fără conexiune la internet verificată).</string>
|
||||
<string name="sync_warning_manage_connections">Gestionează conexiunile</string>
|
||||
<string name="sync_warning_datasaver_enabled">Economizorul de date este activat. Sincronizarea în fundal este restricționată.</string>
|
||||
<string name="sync_warning_manage_datasaver">Gestionează economizorul de date</string>
|
||||
<string name="sync_warning_battery_saver_enabled">Economisirea bateriei este activată. Sincronizarea poate fi restricționată.</string>
|
||||
<string name="sync_warning_manage_battery_saver">Gestionează economisirea bateriei</string>
|
||||
<string name="sync_warning_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="sync_warning_manage_storage">Gestionează stocarea</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_title">Furnizorul de calendar lipsește</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_description">Ai dezactivat aplicația de sistem „Stocare Calendar”?</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_title">Furnizorul de contacte lipsește</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_description">Ai dezactivat aplicația de sistem „Stocare Contacte”?</string>
|
||||
<string name="sync_warning_manage_apps">Gestionează aplicațiile</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_distrust_system_certs_dialog_message">Dacă această setare este activă, certificatele de sistem nu sunt considerate ca fiind de încredere. Aceasta înseamnă că va trebui să accepți manual fiecare certificat (de asemenea, atunci când serverul își reînnoiește certificatul) sau configurarea contului și sincronizarea nu va funcționa.</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_disable">Nimic (dezactivare Push)</string>
|
||||
<string name="app_settings_unifiedpush_choose_distributor">Alege un distribuitor</string>
|
||||
<string name="app_settings_unifiedpush_no_distributor">Nu este instalat un distribuitor push</string>
|
||||
<string name="app_settings_unifiedpush_no_endpoint">Niciun punct final configurat</string>
|
||||
<string name="app_settings_unifiedpush_ready">Gata să primească mesaje push peste %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">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 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,236 +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_enable">啟用</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_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>
|
||||
<!--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_privacy_policy">隱私權政策</string>
|
||||
<!--Sync warnings-->
|
||||
<!--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>
|
||||
<string name="debug_info_archive_caption">ZIP 壓縮檔</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>
|
||||
<!--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_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 name="webdav_add_mount_display_name">顯示名稱</string>
|
||||
<string name="webdav_add_mount_url">WebDAV 網址</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 服務</string>
|
||||
<string name="webdav_remove_mount_title">移除掛點點</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_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,461 +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="dialog_enable">启用</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>
|
||||
<plurals name="intro_open_source_dont_show_months">
|
||||
<item quantity="other">%d 个月</item>
|
||||
</plurals>
|
||||
<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_welcome">欢迎来到 DAVx⁵!</string>
|
||||
<string name="account_list_empty">连接到你的服务器,保持日历和联系人同步</string>
|
||||
<string name="accounts_sync_all">同步所有账户</string>
|
||||
<!--Sync warnings-->
|
||||
<string name="sync_warning_no_notification_permission">已禁用通知。你将不会收到同步出错的通知</string>
|
||||
<string name="sync_warning_no_internet">自动同步不活跃(无已验证的互联网连接)</string>
|
||||
<string name="sync_warning_manage_connections">管理连接</string>
|
||||
<string name="sync_warning_datasaver_enabled">启用了流量节省程序。后台同步受限</string>
|
||||
<string name="sync_warning_manage_datasaver">管理流量节省程序</string>
|
||||
<string name="sync_warning_battery_saver_enabled">启用了节电程序。同步可能受限。</string>
|
||||
<string name="sync_warning_manage_battery_saver">管理节电程序</string>
|
||||
<string name="sync_warning_low_storage">低存储空间。Android 不会立即同步本地更改,但会在下次定期同步时进行</string>
|
||||
<string name="sync_warning_manage_storage">管理存储</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_title">缺少日历程序</string>
|
||||
<string name="sync_warning_calendar_storage_disabled_description">你禁用了“日历存储”系统应用吗?</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_title">缺少联系人程序</string>
|
||||
<string name="sync_warning_contacts_storage_disabled_description">你禁用了“联系人存储”系统应用吗?</string>
|
||||
<string name="sync_warning_manage_apps">管理应用</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_distrust_system_certs_dialog_message">如果此设置处于开启状态,系统证书不会被认为是可信的。这表示你必须手动接受每一个证书(服务器更新其证书时也必须加以确认)或账户设置,且同步不会工作。</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>
|
||||
<string name="app_settings_unifiedpush_distributor_fcm">FCM (Google Play)</string>
|
||||
<string name="app_settings_unifiedpush_encrypted">推送消息始终是加密的</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 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,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,13 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) 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
|
||||
}
|
||||
0
app/.gitignore → core/.gitignore
vendored
0
app/.gitignore → core/.gitignore
vendored
@@ -3,29 +3,20 @@
|
||||
*/
|
||||
|
||||
plugins {
|
||||
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)
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404110003
|
||||
versionName = "4.4.11-rc.2"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 35 // Android 15
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
@@ -51,37 +42,9 @@ 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"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +52,6 @@ android {
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// multiple (test) dependencies have LICENSE files at same location
|
||||
@@ -100,12 +59,20 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
@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"
|
||||
}
|
||||
@@ -119,12 +86,14 @@ ksp {
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||
excludeFields = arrayOf("generated")
|
||||
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)
|
||||
@@ -152,21 +121,22 @@ dependencies {
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(libs.compose.accompanist.permissions)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
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)
|
||||
@@ -174,15 +144,20 @@ dependencies {
|
||||
exclude(group="junit")
|
||||
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
|
||||
}
|
||||
implementation(libs.bitfire.ical4android)
|
||||
implementation(libs.bitfire.vcard4android)
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -201,6 +176,7 @@ dependencies {
|
||||
|
||||
// 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)
|
||||
@@ -211,10 +187,10 @@ dependencies {
|
||||
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)
|
||||
}
|
||||
@@ -24,3 +24,8 @@
|
||||
-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{ *; }
|
||||
648
core/schemas/at.bitfire.davdroid.db.AppDatabase/18.json
Normal file
648
core/schemas/at.bitfire.davdroid.db.AppDatabase/18.json
Normal file
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 18,
|
||||
"identityHash": "6a0f7e1553e1f621ae7913ea14370fd0",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerId",
|
||||
"columnName": "ownerId",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezoneId",
|
||||
"columnName": "timezoneId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushTopic",
|
||||
"columnName": "pushTopic",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsWebPush",
|
||||
"columnName": "supportsWebPush",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushVapidKey",
|
||||
"columnName": "pushVapidKey",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscription",
|
||||
"columnName": "pushSubscription",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionExpires",
|
||||
"columnName": "pushSubscriptionExpires",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionCreated",
|
||||
"columnName": "pushSubscriptionCreated",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_ownerId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"ownerId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_pushTopic_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"pushTopic",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "principal",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"ownerId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "principal",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_principal_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `dataType` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dataType",
|
||||
"columnName": "dataType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_dataType",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"dataType"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_dataType` ON `${TABLE_NAME}` (`collectionId`, `dataType`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_document",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mountId",
|
||||
"columnName": "mountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parentId",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "eTag",
|
||||
"columnName": "eTag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayBind",
|
||||
"columnName": "mayBind",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayUnbind",
|
||||
"columnName": "mayUnbind",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayWriteContent",
|
||||
"columnName": "mayWriteContent",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaAvailable",
|
||||
"columnName": "quotaAvailable",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaUsed",
|
||||
"columnName": "quotaUsed",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_webdav_document_mountId_parentId_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"mountId",
|
||||
"parentId",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_webdav_document_parentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "webdav_mount",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"mountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "webdav_document",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_mount",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6a0f7e1553e1f621ae7913ea14370fd0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
@@ -7,4 +8,9 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!--
|
||||
Since Mockk 1.14.7 it's required to use minSdk 26. We use 24, so override for tests.
|
||||
-->
|
||||
<uses-sdk tools:overrideLibrary="io.mockk.android,io.mockk.proxy.android" />
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalLibrariesTest {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOkhttpHttpUrl_PublicSuffixList() {
|
||||
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
|
||||
// In Android, loading the PublicSuffixList is done over AndroidX startup.
|
||||
// This test verifies that everything is working.
|
||||
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
|
||||
import at.bitfire.davdroid.sync.SyncAdapterService
|
||||
import at.bitfire.synctools.log.LogcatHandler
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@Suppress("unused")
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
@@ -22,13 +24,16 @@ class HiltTestRunner : AndroidJUnitRunner() {
|
||||
override fun onCreate(arguments: Bundle?) {
|
||||
super.onCreate(arguments)
|
||||
|
||||
// set root logger to adb Logcat
|
||||
val rootLogger = Logger.getLogger("")
|
||||
rootLogger.level = Level.ALL
|
||||
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
|
||||
rootLogger.addHandler(LogcatHandler(javaClass.name))
|
||||
|
||||
// MockK requirements
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
|
||||
|
||||
// disable sync adapters
|
||||
SyncAdapterService.syncActive.set(false)
|
||||
|
||||
// set main dispatcher for tests (especially runTest)
|
||||
TestCoroutineDispatchersModule.initMainDispatcher()
|
||||
}
|
||||
@@ -4,22 +4,20 @@
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -29,12 +27,12 @@ import javax.inject.Inject
|
||||
class CollectionTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private lateinit var httpClient: OkHttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
@@ -42,12 +40,6 @@ class CollectionTest {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +61,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
@@ -125,8 +117,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -161,8 +153,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -195,8 +187,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomeSetDaoTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
lateinit var dao: HomeSetDao
|
||||
var serviceId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
dao = db.homeSetDao()
|
||||
|
||||
serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.serviceDao().deleteAll()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.copy(id = 1L), dao.getById(1))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = dao.insertOrUpdateByUrlBlocking(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.copy(id = 1L), dao.getById(1))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = dao.insertOrUpdateByUrlBlocking(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.copy(id = 2L), dao.getById(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate_TransactionSafe() {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
for (i in 0..9999)
|
||||
launch {
|
||||
dao.insertOrUpdateByUrlBlocking(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = serviceId,
|
||||
url = "https://example.com/".toHttpUrl(),
|
||||
personal = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
assertEquals(1, dao.getByService(serviceId).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
// should delete row with given primary key (id)
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, dao.getById(1L))
|
||||
|
||||
dao.delete(entry1)
|
||||
assertEquals(null, dao.getById(1L))
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user