mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-05 20:51:21 -05:00
Compare commits
2 Commits
split-core
...
v2.6.6-ose
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c8caf452 | ||
|
|
363aac1c99 |
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -1,8 +0,0 @@
|
||||
# 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
|
||||
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,4 +0,0 @@
|
||||
|
||||
github: bitfireAT
|
||||
liberapay: DAVx5
|
||||
custom: 'https://www.davx5.com/donate'
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: DAVx⁵ Community Support
|
||||
url: https://github.com/bitfireAT/davx5-ose/discussions
|
||||
about: Ask and answer questions (including feature requests and bug reports) here.
|
||||
44
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
44
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: Qualified Bug Report
|
||||
description: "For qualified bug reports. (Use Discussions if unsure.)"
|
||||
type: bug
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Problem scope
|
||||
description: Use Discussions if you're unsure which component (DAVx⁵, calendar app, server, …) causes your problem.
|
||||
options:
|
||||
- label: I'm sure that this is a DAVx⁵ problem.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: App version
|
||||
options:
|
||||
- label: I'm using the latest available DAVx⁵ version.
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Android version and device/firmware type
|
||||
placeholder: "Android 13 (Samsung A32)"
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide detailed steps to reproduce the problem.
|
||||
placeholder: |
|
||||
1. Create DAVx⁵ account with Some Server (Version).
|
||||
2. Sync Some Calendar.
|
||||
3. SomeException appears.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual result
|
||||
description: Describe what you DAVx⁵ currently does (and what is not expected).
|
||||
placeholder: "Some Property in ICS file causes the whole synchronization to stop."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected result
|
||||
description: Describe what you would expect DAVx⁵ to avoid/solve the problem.
|
||||
placeholder: "Some Property in ICS file should be ignored even if faulty and sync should continue instead of showing an error."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Further info
|
||||
description: Debug info, links to further information, …
|
||||
20
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
20
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: Qualified Feature Request
|
||||
description: "For qualified feature requests. (Use Discussions if unsure.)"
|
||||
type: feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Scope
|
||||
description: Use this form only for features that have been discussed in Discussions or if you're a DAVx5 developer.
|
||||
options:
|
||||
- label: I'm sure that this feature request belongs here and not into Discussions.
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the requested feature and why it is desired.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Further info
|
||||
description: How this could be implemented, links to further information, …
|
||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
@@ -1,32 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Workflow files stored in the default location of `.github/workflows`
|
||||
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
labels:
|
||||
- "github_actions"
|
||||
- "dependencies"
|
||||
groups:
|
||||
ci-actions:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels: # don't create "java" label (default for gradle ecosystem)
|
||||
- "dependencies"
|
||||
groups:
|
||||
app-dependencies:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# dependencies without semantic versioning
|
||||
- dependency-name: "com.github.bitfireat:cert4android"
|
||||
- dependency-name: "com.github.bitfireat:dav4jvm"
|
||||
- dependency-name: "com.github.bitfireat:synctools"
|
||||
38
.github/pull_request_template.md
vendored
38
.github/pull_request_template.md
vendored
@@ -1,38 +0,0 @@
|
||||
|
||||
Please delete this paragraph and other repeating text (like the examples) after reading and before submitting the PR.
|
||||
|
||||
The PR should be in _Draft_ state during development. As soon as it's finished, it should be marked as _Ready for review_ and a reviewer should be chosen.
|
||||
|
||||
See also: [Writing A Great Pull Request Description](https://www.pullrequest.com/blog/writing-a-great-pull-request-description/)
|
||||
|
||||
|
||||
### Purpose
|
||||
|
||||
What this PR is intended to do and why this is desirable.
|
||||
|
||||
Example:
|
||||
|
||||
> Adds support for AAA in BBB, as requested by several people in issue #XX.
|
||||
|
||||
|
||||
### Short description
|
||||
|
||||
A short description of the chosen approach to achieve the purpose.
|
||||
|
||||
Example:
|
||||
|
||||
> - Added authentication option _Some authentication_ to _some module_.
|
||||
> - Added support for _Some authentication_ to _some content provider_.
|
||||
> - Added UI support for _Some authentication_ in account settings.
|
||||
|
||||
Related information (links to Android docs and other resources that help to understand/review
|
||||
the changes) can also be put here.
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] The PR has a proper title, description and label.
|
||||
- [ ] I have [self-reviewed the PR](https://patrickdinh.medium.com/review-your-own-pull-requests-5634cad10b7a).
|
||||
- [ ] I have added documentation to complex functions and functions that can be used by other modules.
|
||||
- [ ] I have added reasonable tests or consciously decided to not add tests.
|
||||
|
||||
20
.github/release.yml
vendored
20
.github/release.yml
vendored
@@ -1,20 +0,0 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: New features
|
||||
labels:
|
||||
- enhancement
|
||||
- title: Bug fixes
|
||||
labels:
|
||||
- bug
|
||||
- title: Refactoring
|
||||
labels:
|
||||
- refactoring
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other changes
|
||||
labels:
|
||||
- "*"
|
||||
50
.github/workflows/codeql.yml
vendored
50
.github/workflows/codeql.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main-ose ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main-ose ]
|
||||
schedule:
|
||||
- cron: '22 10 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual # autobuild uses older JDK
|
||||
|
||||
- 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@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
24
.github/workflows/dependency-submission.yml
vendored
24
.github/workflows/dependency-submission.yml
vendored
@@ -1,24 +0,0 @@
|
||||
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.*'
|
||||
47
.github/workflows/release.yml
vendored
47
.github/workflows/release.yml
vendored
@@ -1,47 +0,0 @@
|
||||
name: Create release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Create release
|
||||
permissions:
|
||||
contents: write
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Prepare keystore
|
||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||
|
||||
- name: Build signed package
|
||||
# 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 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.android_key_alias }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }}
|
||||
|
||||
- name: Create Github release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ env.prerelease }}
|
||||
files: app/build/outputs/apk/ose/release/*.apk
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
136
.github/workflows/test-dev.yml
vendored
136
.github/workflows/test-dev.yml
vendored
@@ -1,136 +0,0 @@
|
||||
name: Development tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main-ose'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We provide a remote gradle build cache. Take the settings from the secrets and enable
|
||||
# configuration and build cache for all gradle jobs.
|
||||
#
|
||||
# Note: The secrets are not available for forks and Dependabot PRs.
|
||||
env:
|
||||
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
|
||||
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
|
||||
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
|
||||
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
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
|
||||
|
||||
- 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') }}
|
||||
|
||||
- name: Compile
|
||||
run: ./gradlew app:compileOseDebugSource
|
||||
|
||||
# Cache configurations for the other jobs (including assemble for CodeQL)
|
||||
- name: Populate configuration cache
|
||||
run: |
|
||||
./gradlew --dry-run core:assembleDebug app:assembleDebug
|
||||
./gradlew --dry-run core:lintDebug app:lintOseDebug
|
||||
./gradlew --dry-run core:testDebugUnitTest app:testOseDebugUnitTest
|
||||
./gradlew --dry-run core:virtualDebugAndroidTest app:virtualOseDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
needs: compile
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
- name: Lint checks
|
||||
run: ./gradlew core:lintDebug app:lintOseDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew core:testDebugUnitTest app:testOseDebugUnitTest
|
||||
|
||||
instrumented_tests:
|
||||
needs: compile
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
# gradle and Android SDK often take more space than what is available on the default runner.
|
||||
# We try to free a few GB here to make gradle-managed devices more reliable.
|
||||
- name: Free some disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
android: false # we need the Android SDK
|
||||
large-packages: false # apt takes too long
|
||||
swap-storage: false # gradle needs much memory
|
||||
|
||||
- name: Restore AVD
|
||||
id: restore-avd
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
# Enable virtualization for Android emulator
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Instrumented tests
|
||||
run: ./gradlew core:virtualDebugAndroidTest app:virtualOseDebugAndroidTest
|
||||
|
||||
- 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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,14 +8,17 @@
|
||||
# Files for the Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java/Kotlin
|
||||
# Java class files
|
||||
*.class
|
||||
.kotlin/
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
|
||||
31
.gitlab-ci.yml
Normal file
31
.gitlab-ci.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
image: registry.gitlab.com/bitfireat/docker-android-emulator:latest
|
||||
|
||||
before_script:
|
||||
- git submodule update --init --recursive
|
||||
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .gradle/
|
||||
|
||||
test:
|
||||
tags:
|
||||
- privileged
|
||||
script:
|
||||
- start-emulator.sh
|
||||
- ./gradlew app:check app:connectedCheck
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/lint-results-debug.html
|
||||
- app/build/reports
|
||||
- build/reports
|
||||
|
||||
pages:
|
||||
script:
|
||||
- ./gradlew app:dokka
|
||||
- mkdir public && mv app/build/dokka public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
only:
|
||||
- master-ose
|
||||
9
.gitmodules
vendored
Normal file
9
.gitmodules
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
[submodule "ical4android"]
|
||||
path = ical4android
|
||||
url = https://gitlab.com/bitfireAT/ical4android.git
|
||||
[submodule "vcard4android"]
|
||||
path = vcard4android
|
||||
url = https://gitlab.com/bitfireAT/vcard4android.git
|
||||
[submodule "cert4android"]
|
||||
path = cert4android
|
||||
url = https://gitlab.com/bitfireAT/cert4android.git
|
||||
9
.idea/codeStyles/Project.xml
generated
9
.idea/codeStyles/Project.xml
generated
@@ -1,9 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<option name="RIGHT_MARGIN" value="180" />
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/copyright/LICENSE.xml
generated
6
.idea/copyright/LICENSE.xml
generated
@@ -1,6 +0,0 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
|
||||
<option name="myName" value="LICENSE" />
|
||||
</copyright>
|
||||
</component>
|
||||
3
.idea/copyright/profiles_settings.xml
generated
3
.idea/copyright/profiles_settings.xml
generated
@@ -1,3 +0,0 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="LICENSE" />
|
||||
</component>
|
||||
7
AUTHORS
7
AUTHORS
@@ -1,7 +0,0 @@
|
||||
You can view the list of people who have contributed to the code base in the version control history:
|
||||
https://github.com/bitfireAT/davx5-ose/graphs/contributors
|
||||
|
||||
Translators are not mentioned in the history explicitly.
|
||||
The list of translators can be found in the About screen.
|
||||
|
||||
Every contribution is welcome. There are many other forms of contributing besides writing code!
|
||||
118
CONTRIBUTING.md
118
CONTRIBUTING.md
@@ -1,99 +1,47 @@
|
||||
|
||||
Contributing to DAVx⁵
|
||||
=====================
|
||||
|
||||
**Thank you for your interest in contributing to DAVx⁵!**
|
||||
|
||||
Because you're reading this, you're probably interested in
|
||||
contributing to the DAVx⁵ code. [Other ways to contribute:
|
||||
see here.](https://www.davx5.com/donate#c306)
|
||||
|
||||
# Licensing
|
||||
To contribute:
|
||||
|
||||
All work in this repository is [licensed under the GPLv3](LICENSE).
|
||||
|
||||
We (bitfire.at, initial and main contributors) are also asking you to give us
|
||||
permission to use your contribution for related non-open source projects
|
||||
like [Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5).
|
||||
|
||||
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.
|
||||
1. It's good idea to have a look at the [DAVx⁵ Roadmap](https://gitlab.com/bitfireAT/davx5-ose/wikis/Roadmap)
|
||||
to see whether the change is already planned. Maybe there's even a link to a
|
||||
corresponding forum thread there.
|
||||
1. Determine which project the changes shall go to. There's
|
||||
the DAVx⁵ main project (this repo), and the [related
|
||||
libraries](README.md).
|
||||
1. Please post to the [DAVx⁵ development forum](https://www.davx5.com/forums)
|
||||
before doing actual work (unless you do it only for yourself, of course).
|
||||
This will help to coordinate activities and you'll also get hints
|
||||
about where to start and possible pitfalls.
|
||||
1. Fork the repository.
|
||||
1. Do the changes in your repository.
|
||||
1. Submit a pull request to the original project.
|
||||
1. Post in the forum again (to make sure the pull request is being notified).
|
||||
|
||||
|
||||
# Copyright notice
|
||||
Questions, discussion
|
||||
=====================
|
||||
|
||||
Make sure that every file that contains significant work (at least every code file)
|
||||
starts with the copyright header. Android Studio should do so automatically because the
|
||||
configuration is stored in the repository (`.idea/copyright`).
|
||||
We're happy to see questions, discussions etc. in the
|
||||
[DAVx⁵ development forum](https://www.davx5.com/forums)!
|
||||
|
||||
|
||||
# Style guide
|
||||
Licensing
|
||||
=========
|
||||
|
||||
Please adhere to the [Kotlin style guide](https://developer.android.com/kotlin/style-guide) and
|
||||
the following hints to make the source code uniform.
|
||||
All code has to be licensed under the GPL.
|
||||
|
||||
**Have a look at similar files and copy their style if you're not certain.**
|
||||
We (bitfire.at, initial developers) are also asking you to double-license the
|
||||
code so that we can also use it for related non-open source projects like
|
||||
[Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5).
|
||||
|
||||
Sample file (pay attention to blank lines and other formatting):
|
||||
|
||||
```
|
||||
<Copyright header, see above>
|
||||
|
||||
class MyClass(int arg1) : SuperClass() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val CONSTANT_STRING = "Constant String";
|
||||
|
||||
fun staticMethod() { // Use static methods when you don't need the object context.
|
||||
// …
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var someProperty: String = "12345"
|
||||
var someRelatedProperty: Int = 12345
|
||||
|
||||
init {
|
||||
// constructor
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Use KDoc to document important methods. Don't use it dogmatically, but writing proper documentation
|
||||
* (not just the method name with spaces) helps you to re-think what the method shall really do.
|
||||
*/
|
||||
fun aFun1() { // Group methods by some logic (for instance, the order in which they will be called)
|
||||
} // and alphabetically within a group.
|
||||
|
||||
fun anotherFun() {
|
||||
// …
|
||||
}
|
||||
|
||||
|
||||
fun somethingCompletelyDifferent() { // two blank lines to separate groups
|
||||
}
|
||||
|
||||
fun helperForSomethingCompletelyDifferent() {
|
||||
someCall(arg1, arg2, arg3, arg4) // function calls: stick to one line unless it becomes confusing
|
||||
}
|
||||
|
||||
|
||||
class Model( // two blank lines before inner classes
|
||||
someArgument: SomeLongClass, // arguments in multiple lines when they're too long for one line
|
||||
anotherArgument: AnotherLongType,
|
||||
thirdArgument: AnotherLongTypeName
|
||||
) : ViewModel() {
|
||||
|
||||
fun abc() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
In general, use one blank line to separate things within one group of things, and two blank lines
|
||||
to separate groups. In rare cases, when methods are tightly coupled and are only helpers for another
|
||||
method, they may follow the calling method without separating blank lines.
|
||||
|
||||
## Tests
|
||||
|
||||
Test classes should be in the appropriate directory (see existing tests) and in the same package as the
|
||||
tested class. Tests are usually be named like `methodToBeTested_Condition()`, see
|
||||
[Test apps on Android](https://developer.android.com/training/testing/).
|
||||
Please find more about this in the Contributor's License Agreement (CLA)
|
||||
we'll send to you if you want to contribute.
|
||||
|
||||
|
||||
46
README.md
46
README.md
@@ -1,47 +1,37 @@
|
||||
|
||||
[](https://fosstodon.org/@davx5app)
|
||||
[](https://www.davx5.com/)
|
||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
DAVx⁵
|
||||
========
|
||||
|
||||
> [!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.
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵.
|
||||
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
News and updates:
|
||||
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
|
||||
|
||||
* [@davx5app@fosstodon.org](https://fosstodon.org/@davx5app) on Mastodon
|
||||
|
||||
**Help, feature requests, bug reports: [DAVx⁵ discussions](https://github.com/bitfireAT/davx5-ose/discussions)**
|
||||
|
||||
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
|
||||
* [synctools](https://github.com/bitfireAT/synctools) – iCalendar/vCard/Tasks processing and content provider access
|
||||
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums)
|
||||
|
||||
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
|
||||
or [purchasing it](https://www.davx5.com/download).**
|
||||
|
||||
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
|
||||
|
||||
Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://gitlab.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework
|
||||
* [ical4android](https://gitlab.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
|
||||
|
||||
USED THIRD-PARTY LIBRARIES
|
||||
==========================
|
||||
|
||||
The most important libraries which are used by DAVx⁵ (alphabetically):
|
||||
Those libraries are used by DAVx⁵ (alphabetically):
|
||||
|
||||
* [dnsjava](https://github.com/dnsjava/dnsjava) – [BSD License](https://github.com/dnsjava/dnsjava/blob/master/LICENSE)
|
||||
* [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](https://github.com/mangstadt/ez-vcard/blob/master/LICENSE)
|
||||
* [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](https://github.com/ical4j/ical4j/blob/develop/LICENSE.txt)
|
||||
* [Color Picker](https://github.com/jaredrummler/ColorPicker) – [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE)
|
||||
* [dnsjava](http://www.xbill.org/dnsjava/) – [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
|
||||
* [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
|
||||
* [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
|
||||
* [okhttp](https://square.github.io/okhttp) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
|
||||
|
||||
See _About / Libraries_ in the app for all used libraries and their licenses.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities using our [secure support form](https://www.davx5.com/support) or via email to support-en@davx5.com.
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules-release.pro")
|
||||
|
||||
isShrinkResources = true
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("bitfire") {
|
||||
storeFile = file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null")
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("virtual") {
|
||||
device = "Pixel 3"
|
||||
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
|
||||
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
|
||||
apiLevel = 34
|
||||
systemImageSource = "aosp-atd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// include core module
|
||||
implementation(project(":core"))
|
||||
|
||||
// Kotlin / Android
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android.base)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
|
||||
// support libs
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.base)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.work.base)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
||||
|
||||
// own libraries
|
||||
implementation(libs.bitfire.cert4android)
|
||||
|
||||
// third-party libs
|
||||
implementation(libs.guava)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.openid.appauth)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<application android:name=".App"/>
|
||||
|
||||
</manifest>
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.di.scope.DefaultDispatcher
|
||||
import at.bitfire.davdroid.log.LogManager
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.ui.UiUtils
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class App: Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
/**
|
||||
* Creates the [at.bitfire.davdroid.log.LogManager] singleton and thus initializes logging.
|
||||
*/
|
||||
@Inject
|
||||
lateinit var logManager: LogManager
|
||||
|
||||
@Inject
|
||||
@DefaultDispatcher
|
||||
lateinit var defaultDispatcher: CoroutineDispatcher
|
||||
|
||||
@Inject
|
||||
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
logger.fine("Logging using LogManager $logManager")
|
||||
|
||||
// set light/dark mode
|
||||
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
|
||||
// some current activity and causes an IllegalStateException in rare cases
|
||||
|
||||
// run startup plugins (sync)
|
||||
for (plugin in plugins.sortedBy { it.priority() }) {
|
||||
logger.fine("Running startup plugin: $plugin (onAppCreate)")
|
||||
plugin.onAppCreate()
|
||||
}
|
||||
|
||||
// don't block UI for some background checks
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(defaultDispatcher) {
|
||||
// clean up orphaned accounts in DB from time to time
|
||||
AccountsCleanupWorker.Companion.enable(this@App)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this@App)
|
||||
|
||||
// run startup plugins (async)
|
||||
for (plugin in plugins.sortedBy { it.priorityAsync() }) {
|
||||
logger.fine("Running startup plugin: $plugin (onAppCreateAsync)")
|
||||
plugin.onAppCreateAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# Set default locale
|
||||
unqualifiedResLocale=en-US
|
||||
@@ -1,27 +0,0 @@
|
||||
<!--
|
||||
~ 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">
|
||||
|
||||
<application>
|
||||
|
||||
<!-- AppAuth login flow redirect -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data
|
||||
tools:ignore="AppLinkUrlError"
|
||||
android:scheme="${applicationId}"
|
||||
android:path="/oauth2/redirect"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class DebugInfoCrashHandler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): Thread.UncaughtExceptionHandler {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DebugInfoCrashHandlerModule {
|
||||
@Binds
|
||||
fun debugInfoCrashHandler(
|
||||
debugInfoCrashHandler: DebugInfoCrashHandler
|
||||
): Thread.UncaughtExceptionHandler
|
||||
}
|
||||
|
||||
// See https://developer.android.com/about/versions/oreo/android-8.0-changes#loue
|
||||
val originalCrashHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
logger.log(Level.SEVERE, "Unhandled exception in thread ${t.id}!", e)
|
||||
|
||||
// start debug info activity with exception (will be started in a new process)
|
||||
val intent = DebugInfoActivity.IntentBuilder(context)
|
||||
.withCause(e)
|
||||
.newTask()
|
||||
.build()
|
||||
context.startActivity(intent)
|
||||
|
||||
// pass through to default handler to kill the process
|
||||
originalCrashHandler?.uncaughtException(t, e)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.cert4android.CustomCertStore
|
||||
import at.bitfire.cert4android.SettingsProvider
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* cert4android integration module
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CustomCertManagerModule {
|
||||
|
||||
@Provides
|
||||
fun customCertStore(@ApplicationContext context: Context): Optional<CustomCertStore> =
|
||||
Optional.of(CustomCertStore.getInstance(context))
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun customCertManager(
|
||||
customCertStore: Optional<CustomCertStore>,
|
||||
settings: SettingsManager
|
||||
): Optional<CustomCertManager> =
|
||||
Optional.of(
|
||||
CustomCertManager(
|
||||
certStore = customCertStore.get(),
|
||||
settings = object : SettingsProvider {
|
||||
|
||||
override val appInForeground: Boolean
|
||||
get() = ForegroundTracker.inForeground.value
|
||||
|
||||
override val trustSystemCerts: Boolean
|
||||
get() = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
|
||||
}
|
||||
))
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun customHostnameVerifier(
|
||||
customCertManager: Optional<CustomCertManager>
|
||||
): Optional<CustomCertManager.HostnameVerifier> =
|
||||
Optional.of(customCertManager.get().HostnameVerifier(OkHostnameVerifier))
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import at.bitfire.davdroid.di.scope.DarkColorScheme
|
||||
import at.bitfire.davdroid.di.scope.LightColorScheme
|
||||
import com.davx5.ose.ui.OseTheme
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class OseColorSchemesModule {
|
||||
|
||||
@Provides
|
||||
@LightColorScheme
|
||||
fun lightColorScheme(): ColorScheme = OseTheme.lightScheme
|
||||
|
||||
@Provides
|
||||
@DarkColorScheme
|
||||
fun darkColorScheme(): ColorScheme = OseTheme.darkScheme
|
||||
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* 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 at.bitfire.davdroid.ui.about.AboutActivity
|
||||
import at.bitfire.davdroid.ui.intro.IntroPageFactory
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
|
||||
import com.davx5.ose.ui.about.OpenSourceLicenseInfoProvider
|
||||
import com.davx5.ose.ui.intro.OseIntroPageFactory
|
||||
import com.davx5.ose.ui.setup.StandardLoginTypesProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityComponent
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
interface OseModules {
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityComponent::class)
|
||||
interface ForActivities {
|
||||
@Binds
|
||||
fun accountsDrawerHandler(impl: OseAccountsDrawerHandler): AccountsDrawerHandler
|
||||
|
||||
@Binds
|
||||
fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
interface ForViewModels {
|
||||
@Binds
|
||||
fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider
|
||||
|
||||
@Binds
|
||||
fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Global {
|
||||
@Binds
|
||||
fun introPageFactory(impl: OseIntroPageFactory): IntroPageFactory
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.ui
|
||||
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object OseTheme {
|
||||
|
||||
// All colors hand-crafted because Material Theme Builder generates unbelievably ugly colors
|
||||
|
||||
val primaryLight = Color(0xFF7cb342)
|
||||
val onPrimaryLight = Color(0xFFffffff)
|
||||
val primaryContainerLight = Color(0xFFb4e47d)
|
||||
val onPrimaryContainerLight = Color(0xFF232d18)
|
||||
val secondaryLight = Color(0xFFff7f2a)
|
||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLight = Color(0xFFffa565)
|
||||
val onSecondaryContainerLight = Color(0xFF3a271b)
|
||||
val tertiaryLight = Color(0xFF658a24)
|
||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLight = Color(0xFFb0d08e)
|
||||
val onTertiaryContainerLight = Color(0xFF263015)
|
||||
val errorLight = Color(0xFFd71717)
|
||||
val onErrorLight = Color(0xFFFFFFFF)
|
||||
val errorContainerLight = Color(0xFFefb6b6)
|
||||
val onErrorContainerLight = Color(0xFF3a0b0b)
|
||||
val backgroundLight = Color(0xFFfcfcfc)
|
||||
val onBackgroundLight = Color(0xFF2a2a2a)
|
||||
val surfaceLight = Color(0xFFf5f5f5)
|
||||
val onSurfaceLight = Color(0xFF4d4d4d)
|
||||
val surfaceVariantLight = Color(0xFFe4e4e4)
|
||||
val onSurfaceVariantLight = Color(0xFF2a2a2a)
|
||||
val outlineLight = Color(0xFF838383)
|
||||
val outlineVariantLight = Color(0xFFd4d4d4)
|
||||
val scrimLight = Color(0xFF000000)
|
||||
val inverseSurfaceLight = Color(0xFF2e322b)
|
||||
val inverseOnSurfaceLight = Color(0xFFfafaf8)
|
||||
val inversePrimaryLight = Color(0xFFb4e47d)
|
||||
val surfaceDimLight = Color(0xFFe3e3e3)
|
||||
val surfaceBrightLight = Color(0xFFf9f9f9)
|
||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLight = Color(0xFFfafafa)
|
||||
val surfaceContainerLight = Color(0xFFf5f5f5)
|
||||
val surfaceContainerHighLight = Color(0xFFf0f0ef)
|
||||
val surfaceContainerHighestLight = Color(0xFFebebea)
|
||||
|
||||
val primaryDark = Color(0xFFc4e3a4)
|
||||
val onPrimaryDark = Color(0xFF2b4310)
|
||||
val primaryContainerDark = Color(0xFF7cb342)
|
||||
val onPrimaryContainerDark = Color(0xFFedf5e4)
|
||||
val secondaryDark = Color(0xFFe5c3ac)
|
||||
val onSecondaryDark = Color(0xFF3e332e)
|
||||
val secondaryContainerDark = Color(0xFFff7f2a)
|
||||
val onSecondaryContainerDark = Color(0xFFffeadb)
|
||||
val tertiaryDark = Color(0xFFc6e597)
|
||||
val onTertiaryDark = Color(0xFF4b661b)
|
||||
val tertiaryContainerDark = Color(0xFF658a24)
|
||||
val onTertiaryContainerDark = Color(0xFFf0f8e2)
|
||||
val errorDark = Color(0xFFf6d0d0)
|
||||
val onErrorDark = Color(0xFF4f1212)
|
||||
val errorContainerDark = Color(0xFFe93434)
|
||||
val onErrorContainerDark = Color(0xFFfcdede)
|
||||
val backgroundDark = Color(0xFF1a1a1a)
|
||||
val onBackgroundDark = Color(0xFFf0f0f0)
|
||||
val surfaceDark = Color(0xFF292929)
|
||||
val onSurfaceDark = Color(0xFFdedede)
|
||||
val surfaceVariantDark = Color(0xFF363636)
|
||||
val onSurfaceVariantDark = Color(0xFFededed)
|
||||
val outlineDark = Color(0xFFa3a3a3)
|
||||
val outlineVariantDark = Color(0xFF7cb342)
|
||||
val scrimDark = Color(0xFF000000)
|
||||
val inverseSurfaceDark = Color(0xFFdbdbdb)
|
||||
val inverseOnSurfaceDark = Color(0xFF292929)
|
||||
val inversePrimaryDark = Color(0xFF7cb342)
|
||||
val surfaceDimDark = Color(0xFF333333)
|
||||
val surfaceBrightDark = Color(0xFF4d4d4d)
|
||||
val surfaceContainerLowestDark = Color(0xFF141414)
|
||||
val surfaceContainerLowDark = Color(0xFF1f1f1f)
|
||||
val surfaceContainerDark = Color(0xff3a3a3a)
|
||||
val surfaceContainerHighDark = Color(0xFF383838)
|
||||
val surfaceContainerHighestDark = Color(0xFF434343)
|
||||
|
||||
|
||||
// Copied from Material Theme Builder: Theme.kt
|
||||
|
||||
val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
onPrimaryContainer = onPrimaryContainerLight,
|
||||
secondary = secondaryLight,
|
||||
onSecondary = onSecondaryLight,
|
||||
secondaryContainer = secondaryContainerLight,
|
||||
onSecondaryContainer = onSecondaryContainerLight,
|
||||
tertiary = tertiaryLight,
|
||||
onTertiary = onTertiaryLight,
|
||||
tertiaryContainer = tertiaryContainerLight,
|
||||
onTertiaryContainer = onTertiaryContainerLight,
|
||||
error = errorLight,
|
||||
onError = onErrorLight,
|
||||
errorContainer = errorContainerLight,
|
||||
onErrorContainer = onErrorContainerLight,
|
||||
background = backgroundLight,
|
||||
onBackground = onBackgroundLight,
|
||||
surface = surfaceLight,
|
||||
onSurface = onSurfaceLight,
|
||||
surfaceVariant = surfaceVariantLight,
|
||||
onSurfaceVariant = onSurfaceVariantLight,
|
||||
outline = outlineLight,
|
||||
outlineVariant = outlineVariantLight,
|
||||
scrim = scrimLight,
|
||||
inverseSurface = inverseSurfaceLight,
|
||||
inverseOnSurface = inverseOnSurfaceLight,
|
||||
inversePrimary = inversePrimaryLight,
|
||||
surfaceDim = surfaceDimLight,
|
||||
surfaceBright = surfaceBrightLight,
|
||||
surfaceContainerLowest = surfaceContainerLowestLight,
|
||||
surfaceContainerLow = surfaceContainerLowLight,
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
onPrimaryContainer = onPrimaryContainerDark,
|
||||
secondary = secondaryDark,
|
||||
onSecondary = onSecondaryDark,
|
||||
secondaryContainer = secondaryContainerDark,
|
||||
onSecondaryContainer = onSecondaryContainerDark,
|
||||
tertiary = tertiaryDark,
|
||||
onTertiary = onTertiaryDark,
|
||||
tertiaryContainer = tertiaryContainerDark,
|
||||
onTertiaryContainer = onTertiaryContainerDark,
|
||||
error = errorDark,
|
||||
onError = onErrorDark,
|
||||
errorContainer = errorContainerDark,
|
||||
onErrorContainer = onErrorContainerDark,
|
||||
background = backgroundDark,
|
||||
onBackground = onBackgroundDark,
|
||||
surface = surfaceDark,
|
||||
onSurface = onSurfaceDark,
|
||||
surfaceVariant = surfaceVariantDark,
|
||||
onSurfaceVariant = onSurfaceVariantDark,
|
||||
outline = outlineDark,
|
||||
outlineVariant = outlineVariantDark,
|
||||
scrim = scrimDark,
|
||||
inverseSurface = inverseSurfaceDark,
|
||||
inverseOnSurface = inverseOnSurfaceDark,
|
||||
inversePrimary = inversePrimaryDark,
|
||||
surfaceDim = surfaceDimDark,
|
||||
surfaceBright = surfaceBrightDark,
|
||||
surfaceContainerLowest = surfaceContainerLowestDark,
|
||||
surfaceContainerLow = surfaceContainerLowDark,
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.ui.about
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Spanned
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.di.scope.IoDispatcher
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.about.AboutActivity
|
||||
import com.google.common.io.CharStreams
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLicenseInfoProvider {
|
||||
|
||||
@Composable
|
||||
override fun LicenseInfo() {
|
||||
LicenseInfoGpl()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LicenseInfoGpl(
|
||||
model: Model = viewModel()
|
||||
) {
|
||||
model.gpl?.let { OpenSourceLicenseInfo(it.toAnnotatedString()) }
|
||||
}
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class Model @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
|
||||
): ViewModel() {
|
||||
|
||||
var gpl by mutableStateOf<Spanned?>(null)
|
||||
|
||||
init {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun OpenSourceLicenseInfo(license: AnnotatedString) {
|
||||
Text(text = license)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun OpenSourceLicenseInfo_Preview() {
|
||||
OpenSourceLicenseInfo(AnnotatedString("It's open-source."))
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
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,
|
||||
tasksIntroPage: TasksIntroPage
|
||||
): IntroPageFactory {
|
||||
|
||||
override val introPages = arrayOf(
|
||||
WelcomePage(),
|
||||
tasksIntroPage,
|
||||
permissionsIntroPage,
|
||||
batteryOptimizationsPage,
|
||||
backupsPage,
|
||||
openSourcePage
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.ui.setup
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.HtmlCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.ExternalUris
|
||||
import at.bitfire.davdroid.ui.ExternalUris.withStatParams
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.Assistant
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.ui.setup.LoginType
|
||||
|
||||
@Composable
|
||||
fun StandardLoginTypePage(
|
||||
selectedLoginType: LoginType,
|
||||
onSelectLoginType: (LoginType) -> Unit,
|
||||
|
||||
@Suppress("unused") // for build variants
|
||||
setInitialLoginInfo: (LoginInfo) -> Unit,
|
||||
|
||||
onContinue: () -> Unit = {}
|
||||
) {
|
||||
Assistant(
|
||||
nextLabel = stringResource(R.string.login_continue),
|
||||
nextEnabled = true,
|
||||
onNext = onContinue
|
||||
) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.login_generic_login),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
for (type in StandardLoginTypesProvider.genericLoginTypes)
|
||||
LoginTypeSelector(
|
||||
title = stringResource(type.title),
|
||||
selected = type == selectedLoginType,
|
||||
onSelect = { onSelectLoginType(type) }
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.login_provider_login),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
for (type in StandardLoginTypesProvider.specificLoginTypes)
|
||||
LoginTypeSelector(
|
||||
title = stringResource(type.title),
|
||||
selected = type == selectedLoginType,
|
||||
onSelect = { onSelectLoginType(type) }
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 12.dp))
|
||||
|
||||
val context = LocalContext.current
|
||||
val privacyPolicy = ExternalUris.Homepage.baseUrl.buildUpon()
|
||||
.appendPath(ExternalUris.Homepage.PATH_PRIVACY)
|
||||
.withStatParams(context, "StandardLoginTypePage")
|
||||
.build().toString()
|
||||
val privacy = HtmlCompat.fromHtml(
|
||||
stringResource(R.string.login_privacy_hint, stringResource(R.string.app_name), privacyPolicy),
|
||||
HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
Text(
|
||||
text = privacy.toAnnotatedString(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginTypeSelector(
|
||||
title: String,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit = {}
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onSelect)
|
||||
.padding(bottom = 4.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onSelect
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun StandardLoginTypePage_Preview() {
|
||||
StandardLoginTypePage(
|
||||
selectedLoginType = StandardLoginTypesProvider.genericLoginTypes.first(),
|
||||
onSelectLoginType = {},
|
||||
setInitialLoginInfo = {},
|
||||
onContinue = {}
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.ui.setup
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import at.bitfire.davdroid.ui.setup.AdvancedLogin
|
||||
import at.bitfire.davdroid.ui.setup.EmailLogin
|
||||
import at.bitfire.davdroid.ui.setup.FastmailLogin
|
||||
import at.bitfire.davdroid.ui.setup.GoogleLogin
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.ui.setup.LoginType
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider.LoginAction
|
||||
import at.bitfire.davdroid.ui.setup.NextcloudLogin
|
||||
import at.bitfire.davdroid.ui.setup.UrlLogin
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class StandardLoginTypesProvider @Inject constructor(
|
||||
private val logger: Logger
|
||||
) : LoginTypesProvider {
|
||||
|
||||
companion object {
|
||||
val genericLoginTypes = listOf(
|
||||
UrlLogin,
|
||||
EmailLogin,
|
||||
AdvancedLogin
|
||||
)
|
||||
|
||||
val specificLoginTypes = listOf(
|
||||
FastmailLogin,
|
||||
GoogleLogin,
|
||||
NextcloudLogin
|
||||
)
|
||||
}
|
||||
|
||||
override val defaultLoginType = UrlLogin
|
||||
|
||||
override fun intentToInitialLoginType(intent: Intent): LoginAction =
|
||||
intent.data?.normalizeScheme().let { uri ->
|
||||
when {
|
||||
intent.hasExtra(LoginActivity.EXTRA_LOGIN_FLOW) ->
|
||||
LoginAction(NextcloudLogin, true)
|
||||
uri?.scheme == "mailto" ->
|
||||
LoginAction(EmailLogin, true)
|
||||
listOf("caldavs", "carddavs", "davx5", "http", "https").any { uri?.scheme == it } ->
|
||||
LoginAction(UrlLogin, true)
|
||||
else -> {
|
||||
logger.warning("Did not understand login intent: $intent")
|
||||
LoginAction(defaultLoginType, false) // Don't skip login type page if intent is unclear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun LoginTypePage(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
selectedLoginType: LoginType,
|
||||
onSelectLoginType: (LoginType) -> Unit,
|
||||
setInitialLoginInfo: (LoginInfo) -> Unit,
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
StandardLoginTypePage(
|
||||
selectedLoginType = selectedLoginType,
|
||||
onSelectLoginType = onSelectLoginType,
|
||||
setInitialLoginInfo = setInitialLoginInfo,
|
||||
onContinue = onContinue
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
0
core/.gitignore → app/.gitignore
vendored
0
core/.gitignore → app/.gitignore
vendored
147
app/build.gradle
Normal file
147
app/build.gradle
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.dokka'
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion '29.0.2'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
|
||||
versionCode 206060000
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
|
||||
minSdkVersion 19 // Android 4.4
|
||||
targetSdkVersion 29 // Android 10.0
|
||||
multiDexEnabled true // >64k methods for Android 4.4
|
||||
|
||||
buildConfigField "String", "okhttpVersion", "\"${versions.okhttp}\""
|
||||
buildConfigField "String", "userAgent", "\"DAVx5\""
|
||||
|
||||
// when using this, make sure that notification icons are real bitmaps
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
dataBinding.enabled = true
|
||||
|
||||
flavorDimensions "distribution"
|
||||
productFlavors {
|
||||
standard {
|
||||
versionName "2.6.6-ose"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
|
||||
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
|
||||
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
|
||||
disable 'RtlEnabled'
|
||||
disable 'RtlHardcoded'
|
||||
disable 'Typos'
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'META-INF/DEPENDENCIES'
|
||||
exclude 'META-INF/LICENSE'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
dokka.configuration {
|
||||
sourceLink {
|
||||
url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/"
|
||||
lineSuffix = "#L"
|
||||
}
|
||||
jdkVersion = 7
|
||||
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/package-list")
|
||||
}
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/package-list")
|
||||
}
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/package-list")
|
||||
}
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/package-list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':cert4android')
|
||||
implementation project(':ical4android')
|
||||
implementation project(':vcard4android')
|
||||
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}"
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:2.1.1'
|
||||
implementation 'androidx.preference:preference:1.1.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01'
|
||||
implementation 'com.google.android:flexbox:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.2.0-alpha05'
|
||||
|
||||
def room_version = '2.2.4'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
|
||||
implementation 'com.gitlab.bitfireAT:dav4jvm:1.0.1'
|
||||
implementation 'com.jaredrummler:colorpicker:1.1.0'
|
||||
implementation('com.mikepenz:aboutlibraries:7.1.0')
|
||||
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
implementation 'dnsjava:dnsjava:2.1.9'
|
||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||
implementation 'org.apache.commons:commons-lang3:3.9'
|
||||
|
||||
// for tests
|
||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.2.0'
|
||||
androidTestImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
}
|
||||
56
app/proguard-rules.txt
Normal file
56
app/proguard-rules.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
|
||||
# ProGuard usage for DAVx⁵:
|
||||
# shrinking yes (main reason for using ProGuard)
|
||||
# optimization yes
|
||||
# obfuscation no (DAVx⁵ is open-source)
|
||||
# preverification no
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
|
||||
-optimizationpasses 5
|
||||
-allowaccessmodification
|
||||
-dontpreverify
|
||||
|
||||
# Kotlin
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# https://github.com/material-components/material-components-android/issues/387
|
||||
-keep class com.google.android.material.tabs.** {*;}
|
||||
|
||||
# Apache Commons
|
||||
-dontwarn javax.script.**
|
||||
|
||||
# ez-vcard
|
||||
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
|
||||
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
|
||||
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
|
||||
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
|
||||
|
||||
# ical4j: ignore unused dynamic libraries
|
||||
-dontwarn aQute.**
|
||||
-dontwarn groovy.** # Groovy-based ContentBuilder not used
|
||||
-dontwarn javax.cache.** # no JCache support in Android
|
||||
-dontwarn net.fortuna.ical4j.model.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
|
||||
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
|
||||
|
||||
# okhttp
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
-dontwarn org.conscrypt.**
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
|
||||
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
|
||||
-keepclassmembers,allowoptimization enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
1
app/src/.gitignore
vendored
Normal file
1
app/src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
espressoTest
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class HttpClientTest {
|
||||
|
||||
lateinit var server: MockWebServer
|
||||
lateinit var httpClient: HttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder().build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCookies() {
|
||||
val url = server.url("/test")
|
||||
|
||||
// set cookie for root path (/) and /test path in first response
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||
.addHeader("Set-Cookie", "cookie2=2")
|
||||
.setBody("Cookie set"))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
|
||||
// cookie should be sent with second request
|
||||
// second response lets first cookie expire and overwrites second cookie
|
||||
server.enqueue(MockResponse()
|
||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||
.addHeader("Set-Cookie", "cookie2=2a")
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CollectionTest {
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder().build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
assertTrue(info.privWriteContent)
|
||||
assertTrue(info.privUnbind)
|
||||
assertNull(info.supportsVEVENT)
|
||||
assertNull(info.supportsVTODO)
|
||||
assertNull(info.supportsVJOURNAL)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timezone)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseWebcal() {
|
||||
// Webcal subscription
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
|
||||
"<response>" +
|
||||
" <href>/webcal1</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <displayname>Sample Subscription</displayname>" +
|
||||
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
|
||||
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
assertEquals("Sample Subscription", info.displayName)
|
||||
assertEquals(HttpUrl.get("https://example.com/1.ics"), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.HttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DaoToolsTest {
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAll() {
|
||||
val serviceDao = db.serviceDao()
|
||||
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
|
||||
service.id = serviceDao.insertOrReplace(service)
|
||||
|
||||
val homeSetDao = db.homeSetDao()
|
||||
val entry1 = HomeSet(id=1, serviceId=service.id, url=HttpUrl.get("https://example.com/1"))
|
||||
val entry3 = HomeSet(id=3, serviceId=service.id, url=HttpUrl.get("https://example.com/3"))
|
||||
val oldItems = listOf(
|
||||
entry1,
|
||||
HomeSet(id=2, serviceId=service.id, url=HttpUrl.get("https://example.com/2")),
|
||||
entry3
|
||||
)
|
||||
homeSetDao.insert(oldItems)
|
||||
|
||||
val newItems = mutableMapOf<HttpUrl, HomeSet>()
|
||||
newItems[entry1.url] = entry1
|
||||
|
||||
// no id, because identity is given by the url
|
||||
val updated = HomeSet(id=0, serviceId=service.id,
|
||||
url=HttpUrl.get("https://example.com/2"), displayName="Updated Entry")
|
||||
newItems[updated.url] = updated
|
||||
|
||||
val created = HomeSet(id=4, serviceId=service.id, url=HttpUrl.get("https://example.com/4"))
|
||||
newItems[created.url] = created
|
||||
|
||||
DaoTools(homeSetDao).syncAll(oldItems, newItems, { it.url })
|
||||
|
||||
val afterSync = homeSetDao.getByService(service.id)
|
||||
assertEquals(afterSync.size, 3)
|
||||
assertFalse(afterSync.contains(entry3))
|
||||
assertTrue(afterSync.contains(entry1))
|
||||
assertTrue(afterSync.contains(updated))
|
||||
assertTrue(afterSync.contains(created))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultsSettingsProviderTest {
|
||||
|
||||
private val provider: SettingsProvider = DefaultsProvider()
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertEquals(Pair(false, true), provider.has("notExisting"))
|
||||
assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGet() {
|
||||
assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST))
|
||||
assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPutRemove() {
|
||||
assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY))
|
||||
assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true))
|
||||
assertFalse(provider.remove(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsTest {
|
||||
|
||||
lateinit var settings: Settings
|
||||
|
||||
@Before
|
||||
fun initialize() {
|
||||
settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertFalse(settings.has("notExisting"))
|
||||
|
||||
// provided by DefaultsProvider
|
||||
assertTrue(settings.has(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.davdroid.syncadapter.SyncAdapterService.SyncAdapter
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SyncAdapterServiceTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testPriorityCollections() {
|
||||
val extras = Bundle()
|
||||
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "")
|
||||
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123")
|
||||
assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,")
|
||||
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3")
|
||||
assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,37 +1,33 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.OkHttpClient
|
||||
import android.app.Application
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavResourceFinderTest {
|
||||
|
||||
companion object {
|
||||
@@ -45,53 +41,42 @@ class DavResourceFinderTest {
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
val server = MockWebServer()
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var finder: DavResourceFinder
|
||||
lateinit var finder: DavResourceFinder
|
||||
lateinit var client: HttpClient
|
||||
lateinit var loginModel: LoginModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
fun initServerAndClient() {
|
||||
server.setDispatcher(TestDispatcher())
|
||||
server.start()
|
||||
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
loginModel = LoginModel()
|
||||
loginModel.baseURI = URI.create("/")
|
||||
loginModel.credentials = Credentials("mock", "12345")
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
||||
client = httpClientBuilder
|
||||
.authenticate(domain = null, getCredentials = { credentials })
|
||||
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
|
||||
client = HttpClient.Builder()
|
||||
.addAuthentication(null, loginModel.credentials!!)
|
||||
.build()
|
||||
|
||||
val baseURI = URI.create("/")
|
||||
finder = resourceFinderFactory.create(baseURI, credentials)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
fun stopServer() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanCardDavResponse(response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
@@ -99,9 +84,9 @@ class DavResourceFinderTest {
|
||||
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanCardDavResponse(response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
@@ -141,33 +126,21 @@ class DavResourceFinderTest {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryEmailAddress() {
|
||||
var info = ServiceInfo()
|
||||
assertArrayEquals(
|
||||
arrayOf("email1@example.com", "email2@example.com"),
|
||||
finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray()
|
||||
)
|
||||
assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(request)) {
|
||||
override fun dispatch(rq: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(rq)) {
|
||||
val authenticate = MockResponse().setResponseCode(401)
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
|
||||
return authenticate
|
||||
}
|
||||
|
||||
val path = request.path!!
|
||||
val path = rq.path
|
||||
|
||||
if (request.method.equals("OPTIONS", true)) {
|
||||
if (rq.method.equals("OPTIONS", true)) {
|
||||
val dav = when {
|
||||
path.startsWith(PATH_CALDAV) -> "calendar-access"
|
||||
path.startsWith(PATH_CARDDAV) -> "addressbook"
|
||||
@@ -178,7 +151,7 @@ class DavResourceFinderTest {
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav)
|
||||
return response
|
||||
} else if (request.method.equals("PROPFIND", true)) {
|
||||
} else if (rq.method.equals("PROPFIND", true)) {
|
||||
val props: String?
|
||||
when (path) {
|
||||
PATH_CALDAV,
|
||||
@@ -196,21 +169,14 @@ class DavResourceFinderTest {
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
props = "<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>"
|
||||
|
||||
else -> props = null
|
||||
}
|
||||
logger.info("Sending props: $props")
|
||||
Logger.log.info("Sending props: $props")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>${request.path}</href>" +
|
||||
" <href>${rq.path}</href>" +
|
||||
" <propstat><prop>$props</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
BIN
app/src/androidTest/res/drawable-hdpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
app/src/androidTest/res/drawable-ldpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-ldpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/androidTest/res/drawable-mdpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/src/androidTest/res/drawable-xhdpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
14
app/src/androidTest/res/values/strings.xml
Normal file
14
app/src/androidTest/res/values/strings.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="app_name">DavdroidTest</string>
|
||||
|
||||
</resources>
|
||||
199
app/src/main/AndroidManifest.xml
Normal file
199
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,199 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="at.bitfire.davdroid"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- normal permissions -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!-- other permissions -->
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<!-- android.permission-group.CALENDAR -->
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- android.permission-group.LOCATION -->
|
||||
<!-- getting the WiFi name (for "sync in Wifi only") requires
|
||||
- coarse location (Android 8.1)
|
||||
- fine location (Android 10) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
|
||||
|
||||
<!-- ical4android declares task access permissions -->
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<service android:name=".DavService"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:label="@string/login_title"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"/>
|
||||
<activity android:name=".ui.account.SettingsActivity"/>
|
||||
<activity android:name=".ui.CreateAddressBookActivity"
|
||||
android:label="@string/create_addressbook"/>
|
||||
<activity android:name=".ui.CreateCalendarActivity"
|
||||
android:label="@string/create_calendar"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/authority_debug_provider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/debug_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- account type "DAVx⁵" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.TasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_tasks"/>
|
||||
</service>
|
||||
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<provider
|
||||
android:authorities="@string/address_books_authority"
|
||||
android:exported="false"
|
||||
android:label="@string/address_books_authority_title"
|
||||
android:name=".syncadapter.AddressBookProvider"
|
||||
android:multiprocess="false"/>
|
||||
<service
|
||||
android:name=".syncadapter.AddressBooksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_address_books"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_contacts"/>
|
||||
<meta-data
|
||||
android:name="android.provider.CONTACTS_STRUCTURE"
|
||||
android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
100
app/src/main/java/at/bitfire/davdroid/App.kt
Normal file
100
app/src/main/java/at/bitfire/davdroid/App.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@Suppress("unused")
|
||||
class App: MultiDexApplication(), Thread.UncaughtExceptionHandler {
|
||||
|
||||
companion object {
|
||||
|
||||
fun getLauncherBitmap(context: Context): Bitmap? {
|
||||
val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)
|
||||
return if (drawableLogo is BitmapDrawable)
|
||||
drawableLogo.bitmap
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun homepageUrl(context: Context) =
|
||||
Uri.parse(context.getString(R.string.homepage_url)).buildUpon()
|
||||
.appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
|
||||
.appendQueryParameter("pk_kwd", context::class.java.simpleName)
|
||||
.appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
|
||||
.build()!!
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
// debug builds
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
|
||||
// handle uncaught exceptions in non-debug standard flavor
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT <= 21)
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// don't block UI for some background checks
|
||||
thread {
|
||||
// watch installed/removed apps
|
||||
val tasksFilter = IntentFilter()
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
tasksFilter.addDataScheme("package")
|
||||
registerReceiver(PackageChangedReceiver(), tasksFilter)
|
||||
|
||||
// check whether a tasks app is currently installed
|
||||
PackageChangedReceiver.updateTaskSync(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
|
||||
|
||||
val intent = Intent(this, DebugInfoActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
startActivity(intent)
|
||||
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
}
|
||||
12
app/src/main/java/at/bitfire/davdroid/CompatUtils.kt
Normal file
12
app/src/main/java/at/bitfire/davdroid/CompatUtils.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.ContentProviderClient
|
||||
import android.os.Build
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun ContentProviderClient.closeCompat() {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
close()
|
||||
else
|
||||
release()
|
||||
}
|
||||
30
app/src/main/java/at/bitfire/davdroid/Constants.kt
Normal file
30
app/src/main/java/at/bitfire/davdroid/Constants.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
object Constants {
|
||||
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
|
||||
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
|
||||
|
||||
/**
|
||||
* Context label for [org.apache.commons.lang3.exception.ContextedException].
|
||||
* Context value is the [at.bitfire.davdroid.resource.LocalResource]
|
||||
* which is related to the exception cause.
|
||||
*/
|
||||
const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource"
|
||||
|
||||
/**
|
||||
* Context label for [org.apache.commons.lang3.exception.ContextedException].
|
||||
* Context value is the [okhttp3.HttpUrl] of the remote resource
|
||||
* which is related to the exception cause.
|
||||
*/
|
||||
const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource"
|
||||
|
||||
}
|
||||
375
app/src/main/java/at/bitfire/davdroid/DavService.kt
Normal file
375
app/src/main/java/at/bitfire/davdroid/DavService.kt
Normal file
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.room.Transaction
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.*
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DavService: android.app.Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
const val EXTRA_DAV_SERVICE_ID = "davServiceID"
|
||||
|
||||
/** Initialize a forced synchronization. Expects intent data
|
||||
to be an URI of this format:
|
||||
contents://<authority>/<account.type>/<account name>
|
||||
**/
|
||||
const val ACTION_FORCE_SYNC = "forceSync"
|
||||
|
||||
val DAV_COLLECTION_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.let {
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
refreshingStatusListeners.forEach { listener ->
|
||||
listener.get()?.onDavRefreshStatusChanged(id, true)
|
||||
}
|
||||
thread { refreshCollections(id) }
|
||||
}
|
||||
|
||||
ACTION_FORCE_SYNC -> {
|
||||
val uri = intent.data!!
|
||||
val authority = uri.authority!!
|
||||
val account = Account(
|
||||
uri.pathSegments[1],
|
||||
uri.pathSegments[0]
|
||||
)
|
||||
forceSync(authority, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
|
||||
/* BOUND SERVICE PART
|
||||
for communicating with the activities
|
||||
*/
|
||||
|
||||
interface RefreshingStatusListener {
|
||||
fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean)
|
||||
}
|
||||
|
||||
private val binder = InfoBinder()
|
||||
|
||||
inner class InfoBinder: Binder() {
|
||||
fun isRefreshing(id: Long) = runningRefresh.contains(id)
|
||||
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
|
||||
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
|
||||
if (callImmediateIfRunning)
|
||||
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
|
||||
val iter = refreshingStatusListeners.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val item = iter.next().get()
|
||||
if (listener == item)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
|
||||
|
||||
/* ACTION RUNNABLES
|
||||
which actually do the work
|
||||
*/
|
||||
|
||||
private fun forceSync(authority: String, account: Account) {
|
||||
Logger.log.info("Forcing $authority synchronization of $account")
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
|
||||
private fun refreshCollections(serviceId: Long) {
|
||||
val db = AppDatabase.getInstance(this)
|
||||
val homeSetDao = db.homeSetDao()
|
||||
val collectionDao = db.collectionDao()
|
||||
|
||||
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, getString(R.string.account_type))
|
||||
|
||||
val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
|
||||
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
*/
|
||||
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
|
||||
val related = mutableSetOf<HttpUrl>()
|
||||
|
||||
fun findRelated(root: HttpUrl, dav: Response) {
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
dav[CalendarProxyReadFor::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
root.resolve(href)?.let { proxyReadFor ->
|
||||
related += proxyReadFor
|
||||
}
|
||||
}
|
||||
}
|
||||
dav[CalendarProxyWriteFor::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
root.resolve(href)?.let { proxyWriteFor ->
|
||||
related += proxyWriteFor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
dav[GroupMembership::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
root.resolve(href)?.let { groupMembership ->
|
||||
related += groupMembership
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dav = DavResource(client, url)
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV ->
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[AddressbookHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
Service.TYPE_CALDAV -> {
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[CalendarHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (resource in related)
|
||||
queryHomeSets(client, resource, false)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun saveHomesets() {
|
||||
DaoTools(homeSetDao).syncAll(
|
||||
homeSetDao.getByService(serviceId),
|
||||
homeSets,
|
||||
{ it.url })
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun saveCollections() {
|
||||
DaoTools(collectionDao).syncAll(
|
||||
collectionDao.getByService(serviceId),
|
||||
collections, { it.url }) { new, old ->
|
||||
new.forceReadOnly = old.forceReadOnly
|
||||
new.sync = old.sync
|
||||
}
|
||||
}
|
||||
|
||||
fun saveResults() {
|
||||
saveHomesets()
|
||||
saveCollections()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing ${service.type} collections of service #$service")
|
||||
|
||||
// cancel previous notification
|
||||
NotificationManagerCompat.from(this)
|
||||
.cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
HttpClient.Builder(this, AccountSettings(this, account))
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
// refresh home set list (from principal)
|
||||
service.principal?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
queryHomeSets(httpClient, principalUrl)
|
||||
}
|
||||
|
||||
// now refresh homesets and their member collections
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val homeSet = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set ${homeSet.key}")
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSet.key).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
// this response is about the homeset itself
|
||||
homeSet.value.displayName = response[DisplayName::class.java]?.displayName
|
||||
homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
|
||||
}
|
||||
|
||||
// in any case, check whether the response is about a useable collection
|
||||
val info = Collection.fromDavResponse(response) ?: return@propfind
|
||||
info.serviceId = serviceId
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
// remember usable collections
|
||||
if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type)))
|
||||
collections[response.href] = info
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// check/refresh unconfirmed collections
|
||||
val itCollections = collections.entries.iterator()
|
||||
while (itCollections.hasNext()) {
|
||||
val (url, info) = itCollections.next()
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
val collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection.confirmed = true
|
||||
|
||||
// remove unusable collections
|
||||
if ((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))
|
||||
itCollections.remove()
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveResults()
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account", e)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = Intent(this, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
|
||||
val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(serviceId)
|
||||
refreshingStatusListeners.mapNotNull { it.get() }.forEach {
|
||||
it.onDavRefreshStatusChanged(serviceId, false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
108
app/src/main/java/at/bitfire/davdroid/DavUtils.kt
Normal file
108
app/src/main/java/at/bitfire/davdroid/DavUtils.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import org.xbill.DNS.*
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Some WebDAV and related network utility methods
|
||||
*/
|
||||
object DavUtils {
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format("#%06X%02X", color, alpha)
|
||||
}
|
||||
|
||||
|
||||
fun lastSegmentOfUrl(url: HttpUrl): String {
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
val segments = LinkedList<String>(url.pathSegments())
|
||||
segments.reverse()
|
||||
|
||||
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
|
||||
}
|
||||
|
||||
fun prepareLookup(context: Context, lookup: Lookup) {
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
|
||||
The current version of dnsjava relies on these properties to find the default name servers,
|
||||
so we have to add the servers explicitly (fortunately, there's an Android API to
|
||||
get the active DNS servers). */
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
|
||||
if (activeLink != null) {
|
||||
// get DNS servers of active network link and set them for dnsjava so that it can send SRV queries
|
||||
val simpleResolvers = activeLink.dnsServers.map {
|
||||
Logger.log.fine("Using DNS server ${it.hostAddress}")
|
||||
val resolver = SimpleResolver()
|
||||
resolver.setAddress(it)
|
||||
resolver
|
||||
}
|
||||
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
lookup.setResolver(resolver)
|
||||
} else
|
||||
Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work")
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
|
||||
val srvRecords = records?.filterIsInstance(SRVRecord::class.java)
|
||||
srvRecords?.let {
|
||||
if (it.size > 1)
|
||||
Logger.log.warning("Multiple SRV records not supported yet; using first one")
|
||||
return it.firstOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun pathsFromTXTRecords(records: Array<Record>?): List<String> {
|
||||
val paths = LinkedList<String>()
|
||||
records?.filterIsInstance(TXTRecord::class.java)?.forEach { txt ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
for (segment in txt.strings as List<String>)
|
||||
if (segment.startsWith("path=")) {
|
||||
paths.add(segment.substring(5))
|
||||
break
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
|
||||
fun requestSync(context: Context, account: Account) {
|
||||
val authorities = arrayOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.OpenTasks.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
251
app/src/main/java/at/bitfire/davdroid/HttpClient.kt
Normal file
251
app/src/main/java/at/bitfire/davdroid/HttpClient.kt
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.*
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.*
|
||||
|
||||
class HttpClient private constructor(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val certManager: CustomCertManager?
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** max. size of disk cache (10 MB) */
|
||||
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
|
||||
|
||||
/** [OkHttpClient] singleton to build all clients from */
|
||||
val sharedClient: OkHttpClient = OkHttpClient.Builder()
|
||||
// set timeouts
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache()?.close()
|
||||
certManager?.close()
|
||||
}
|
||||
|
||||
class Builder(
|
||||
val context: Context? = null,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: java.util.logging.Logger = Logger.log
|
||||
) {
|
||||
private var certManager: CustomCertManager? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var cache: Cache? = null
|
||||
|
||||
private val orig = sharedClient.newBuilder()
|
||||
|
||||
init {
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
orig.cookieJar(MemoryCookieStore())
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
|
||||
message -> logger.finest(message)
|
||||
})
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||
orig.addInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
context?.let {
|
||||
val settings = Settings.getInstance(context)
|
||||
|
||||
// custom proxy support
|
||||
try {
|
||||
if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) {
|
||||
val address = InetSocketAddress(
|
||||
settings.getString(Settings.OVERRIDE_PROXY_HOST)
|
||||
?: Settings.OVERRIDE_PROXY_HOST_DEFAULT,
|
||||
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
|
||||
?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
)
|
||||
|
||||
val proxy = Proxy(Proxy.Type.HTTP, address)
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
//if (BuildConfig.customCerts)
|
||||
customCertManager(CustomCertManager(context, true,
|
||||
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT)))
|
||||
}
|
||||
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
Logger.log.fine("Using disk cache: $cacheDir")
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
orig.followRedirects(follow)
|
||||
return this
|
||||
}
|
||||
|
||||
fun customCertManager(manager: CustomCertManager) {
|
||||
certManager = manager
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
certManager?.appInForeground = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, credentials: Credentials): Builder {
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword -> {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
|
||||
orig .addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
Credentials.Type.ClientCertificate -> {
|
||||
certificateAlias = credentials.certificateAlias
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): HttpClient {
|
||||
val trustManager = certManager ?: {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}()
|
||||
|
||||
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
|
||||
?: OkHostnameVerifier.INSTANCE
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
certificateAlias?.let { alias ->
|
||||
try {
|
||||
val context = requireNotNull(context)
|
||||
|
||||
// get provider certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create Android KeyStore (performs key operations without revealing secret data to DAVx5)
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||
keyStore.load(null)
|
||||
|
||||
// create KeyManager
|
||||
keyManager = object: X509ExtendedKeyManager() {
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
|
||||
alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
}
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
orig.protocols(listOf(Protocol.HTTP_1_1))
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e)
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else null,
|
||||
arrayOf(trustManager),
|
||||
null)
|
||||
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
|
||||
return HttpClient(orig.build(), certManager)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private object UserAgentInterceptor: Interceptor {
|
||||
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
"okhttp/${BuildConfig.okhttpVersion}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
|
||||
class InvalidAccountException(account: Account): Exception("Invalid account: $account")
|
||||
63
app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt
Normal file
63
app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.collections4.keyvalue.MultiKey
|
||||
import org.apache.commons.collections4.map.HashedMap
|
||||
import org.apache.commons.collections4.map.MultiKeyMap
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Primitive cookie store that stores cookies in a (volatile) hash map.
|
||||
* Will be sufficient for session cookies.
|
||||
*/
|
||||
class MemoryCookieStore: CookieJar {
|
||||
|
||||
/**
|
||||
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
|
||||
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
|
||||
* Not thread-safe!
|
||||
*/
|
||||
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(storage) {
|
||||
for (cookie in cookies)
|
||||
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val cookies = LinkedList<Cookie>()
|
||||
|
||||
synchronized(storage) {
|
||||
val iter = storage.mapIterator()
|
||||
while (iter.hasNext()) {
|
||||
iter.next()
|
||||
val cookie = iter.value
|
||||
|
||||
// remove expired cookies
|
||||
if (cookie.expiresAt() <= System.currentTimeMillis()) {
|
||||
iter.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
// add applicable cookies
|
||||
if (cookie.matches(url))
|
||||
cookies += cookie
|
||||
}
|
||||
}
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class PackageChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Tasks provider available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
val db = AppDatabase.getInstance(context)
|
||||
db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service ->
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
if (tasksInstalled) {
|
||||
if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) {
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 1)
|
||||
ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
thread {
|
||||
updateTaskSync(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
50
app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt
Normal file
50
app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.util.Log
|
||||
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
object LogcatHandler: Handler() {
|
||||
|
||||
private const val MAX_LINE_LENGTH = 3000
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
level = Level.ALL
|
||||
}
|
||||
|
||||
override fun publish(r: LogRecord) {
|
||||
val text = formatter.format(r)
|
||||
val level = r.level.intValue()
|
||||
|
||||
val end = text.length
|
||||
var pos = 0
|
||||
while (pos < end) {
|
||||
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
|
||||
when {
|
||||
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
|
||||
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
|
||||
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
|
||||
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
|
||||
else -> Log.v(r.loggerName, line)
|
||||
}
|
||||
pos += MAX_LINE_LENGTH
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
}
|
||||
137
app/src/main/java/at/bitfire/davdroid/log/Logger.kt
Normal file
137
app/src/main/java/at/bitfire/davdroid/log/Logger.kt
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
|
||||
@SuppressLint("StaticFieldLeak") // we'll only keep an app context
|
||||
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private const val LOG_TO_FILE = "log_to_file"
|
||||
|
||||
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger("davx5")
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var preferences: SharedPreferences
|
||||
|
||||
fun initialize(someContext: Context) {
|
||||
context = someContext.applicationContext
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
reinitialize()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (key == LOG_TO_FILE) {
|
||||
log.info("Logging settings changed; re-initializing logger")
|
||||
reinitialize()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reinitialize() {
|
||||
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
|
||||
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
log.info("Verbose logging: $logVerbose; to file: $logToFile")
|
||||
|
||||
// set logging level according to preferences
|
||||
val rootLogger = java.util.logging.Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
|
||||
// remove all handlers and add our own logcat handler
|
||||
rootLogger.useParentHandlers = false
|
||||
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
|
||||
rootLogger.addHandler(LogcatHandler)
|
||||
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
|
||||
.setContentTitle(context.getString(R.string.logging_notification_title))
|
||||
|
||||
val logDir = debugDir(context) ?: return
|
||||
val logFile = File(logDir, "davx5-log.txt")
|
||||
|
||||
try {
|
||||
val fileHandler = FileHandler(logFile.toString(), true)
|
||||
fileHandler.formatter = PlainTextFormatter.DEFAULT
|
||||
rootLogger.addHandler(fileHandler)
|
||||
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
|
||||
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
builder .setContentText(logDir.path)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentText(context.getString(R.string.logging_notification_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setOngoing(true)
|
||||
|
||||
// add "Share" action
|
||||
val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile)
|
||||
log.fine("Now logging to file: $logFile -> $logFileUri")
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs")
|
||||
shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
|
||||
shareIntent.type = "text/plain"
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val chooserIntent = Intent.createChooser(shareIntent, null)
|
||||
val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_notify,
|
||||
context.getString(R.string.logging_notification_send_log),
|
||||
PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
builder.addAction(shareAction.build())
|
||||
} catch(e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create log file", e)
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} else {
|
||||
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
|
||||
|
||||
// delete old logs
|
||||
debugDir(context)?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun debugDir(context: Context): File? {
|
||||
val dir = File(context.filesDir, "debug")
|
||||
if (dir.exists() && dir.isDirectory)
|
||||
return dir
|
||||
|
||||
if (dir.mkdir())
|
||||
return dir
|
||||
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.util.logging.Formatter
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class PlainTextFormatter private constructor(
|
||||
private val logcat: Boolean
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
val LOGCAT = PlainTextFormatter(true)
|
||||
val DEFAULT = PlainTextFormatter(false)
|
||||
|
||||
const val MAX_MESSAGE_LENGTH = 20000
|
||||
}
|
||||
|
||||
override fun format(r: LogRecord): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
if (!logcat)
|
||||
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss"))
|
||||
.append(" ").append(r.threadID).append(" ")
|
||||
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
if (className != r.loggerName)
|
||||
builder.append("[").append(className).append("] ")
|
||||
|
||||
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
|
||||
|
||||
r.thrown?.let {
|
||||
builder .append("\nEXCEPTION ")
|
||||
.append(ExceptionUtils.getStackTrace(it))
|
||||
}
|
||||
|
||||
r.parameters?.let {
|
||||
for ((idx, param) in it.withIndex())
|
||||
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
|
||||
}
|
||||
|
||||
if (!logcat)
|
||||
builder.append("\n")
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun shortClassName(className: String) = className
|
||||
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
|
||||
}
|
||||
31
app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt
Normal file
31
app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class StringHandler: Handler() {
|
||||
|
||||
val builder = StringBuilder()
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.DEFAULT
|
||||
}
|
||||
|
||||
override fun publish(record: LogRecord) {
|
||||
builder.append(formatter.format(record))
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
override fun toString() = builder.toString()
|
||||
|
||||
}
|
||||
224
app/src/main/java/at/bitfire/davdroid/model/AppDatabase.kt
Normal file
224
app/src/main/java/at/bitfire/davdroid/model/AppDatabase.kt
Normal file
@@ -0,0 +1,224 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class
|
||||
], version = 7)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
|
||||
companion object {
|
||||
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
@Synchronized
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
INSTANCE?.let { return it }
|
||||
|
||||
val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(
|
||||
Migration1_2,
|
||||
Migration2_3,
|
||||
Migration3_4,
|
||||
Migration4_5,
|
||||
Migration5_6,
|
||||
Migration6_7
|
||||
)
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.build()
|
||||
INSTANCE = db
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query("SELECT * FROM $table").use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"))
|
||||
else
|
||||
sb.append("<null>")
|
||||
|
||||
} catch (e: SQLiteException) {
|
||||
sb.append("<unprintable>")
|
||||
}
|
||||
sb.append(" |")
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
sb.append("----------\n")
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// migrations
|
||||
|
||||
object Migration6_7: Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration5_6: Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
}
|
||||
|
||||
object Migration4_5: Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
}
|
||||
|
||||
object Migration3_4: Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration2_3: Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
object Migration1_2: Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
157
app/src/main/java/at/bitfire/davdroid/model/Collection.kt
Normal file
157
app/src/main/java/at/bitfire/davdroid/model/Collection.kt
Normal file
@@ -0,0 +1,157 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.*
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
Index("serviceId","type")
|
||||
]
|
||||
)
|
||||
data class Collection(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long = 0,
|
||||
|
||||
var serviceId: Long = 0,
|
||||
|
||||
var type: String,
|
||||
var url: HttpUrl,
|
||||
|
||||
var privWriteContent: Boolean = true,
|
||||
var privUnbind: Boolean = true,
|
||||
var forceReadOnly: Boolean = false,
|
||||
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
var color: Int? = null,
|
||||
|
||||
/** timezone definition (full VTIMEZONE) - not a TZID! **/
|
||||
var timezone: String? = null,
|
||||
|
||||
/** whether the collection supports VEVENT; in case of calendars: null means true */
|
||||
var supportsVEVENT: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VTODO; in case of calendars: null means true */
|
||||
var supportsVTODO: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
|
||||
var supportsVJOURNAL: Boolean? = null,
|
||||
|
||||
/** Webcal subscription source URL */
|
||||
var source: HttpUrl? = null,
|
||||
|
||||
/** whether this collection has been selected for synchronization */
|
||||
var sync: Boolean = false
|
||||
|
||||
): IdEntity() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK"
|
||||
const val TYPE_CALENDAR = "CALENDAR"
|
||||
const val TYPE_WEBCAL = "WEBCAL"
|
||||
|
||||
/**
|
||||
* Generates a collection entity from a WebDAV response.
|
||||
* @param dav WebDAV response
|
||||
* @return null if the response doesn't represent a collection
|
||||
*/
|
||||
fun fromDavResponse(dav: Response): Collection? {
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
var privWriteContent = true
|
||||
var privUnbind = true
|
||||
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
privWriteContent = privilegeSet.mayWriteContent
|
||||
privUnbind = privilegeSet.mayUnbind
|
||||
}
|
||||
|
||||
var displayName: String? = null
|
||||
dav[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
var timezone: String? = null
|
||||
var supportsVEVENT: Boolean? = null
|
||||
var supportsVTODO: Boolean? = null
|
||||
var supportsVJOURNAL: Boolean? = null
|
||||
var source: HttpUrl? = null
|
||||
when (type) {
|
||||
TYPE_ADDRESSBOOK -> {
|
||||
dav[AddressbookDescription::class.java]?.let { description = it.description }
|
||||
}
|
||||
TYPE_CALENDAR, TYPE_WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
|
||||
|
||||
if (type == TYPE_CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
supportsVJOURNAL = true
|
||||
dav[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
supportsVJOURNAL = it.supportsJournal
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull()?.let { rawHref ->
|
||||
val href = rawHref
|
||||
.replace("^webcal://".toRegex(), "http://")
|
||||
.replace("^webcals://".toRegex(), "https://")
|
||||
HttpUrl.parse(href)
|
||||
} }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collection(
|
||||
type = type,
|
||||
url = url,
|
||||
privWriteContent = privWriteContent,
|
||||
privUnbind = privUnbind,
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timezone,
|
||||
supportsVEVENT = supportsVEVENT,
|
||||
supportsVTODO = supportsVTODO,
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// non-persistent properties
|
||||
@Ignore
|
||||
var confirmed: Boolean = false
|
||||
|
||||
|
||||
// calculated properties
|
||||
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
|
||||
fun readOnly() = forceReadOnly || !privWriteContent
|
||||
|
||||
}
|
||||
43
app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt
Normal file
43
app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface CollectionDao: SyncableDao<Collection> {
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun get(id: Long): Collection?
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
|
||||
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url")
|
||||
fun getByServiceAndSync(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync")
|
||||
fun observeHasSyncByService(serviceId: Long): LiveData<Boolean>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url")
|
||||
fun getSyncCalendars(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url")
|
||||
fun getSyncTaskLists(serviceId: Long): List<Collection>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(collection: Collection)
|
||||
|
||||
@Insert
|
||||
fun insert(collection: Collection)
|
||||
|
||||
}
|
||||
16
app/src/main/java/at/bitfire/davdroid/model/Converters.kt
Normal file
16
app/src/main/java/at/bitfire/davdroid/model/Converters.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun httpUrlToString(url: HttpUrl?) =
|
||||
url?.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun stringToHttpUrl(url: String?): HttpUrl? =
|
||||
url?.let { HttpUrl.parse(it) }
|
||||
|
||||
}
|
||||
38
app/src/main/java/at/bitfire/davdroid/model/Credentials.kt
Normal file
38
app/src/main/java/at/bitfire/davdroid/model/Credentials.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
class Credentials(
|
||||
val userName: String? = null,
|
||||
val password: String? = null,
|
||||
val certificateAlias: String? = null
|
||||
) {
|
||||
|
||||
enum class Type {
|
||||
UsernamePassword,
|
||||
ClientCertificate
|
||||
}
|
||||
|
||||
val type: Type
|
||||
|
||||
init {
|
||||
type = when {
|
||||
!certificateAlias.isNullOrEmpty() ->
|
||||
Type.ClientCertificate
|
||||
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
|
||||
Type.UsernamePassword
|
||||
else ->
|
||||
throw IllegalArgumentException("Either username/password or certificate alias must be set")
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() =
|
||||
"Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
|
||||
|
||||
}
|
||||
41
app/src/main/java/at/bitfire/davdroid/model/DaoTools.kt
Normal file
41
app/src/main/java/at/bitfire/davdroid/model/DaoTools.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.util.logging.Level
|
||||
|
||||
class DaoTools<T: IdEntity>(dao: SyncableDao<T>): SyncableDao<T> by dao {
|
||||
|
||||
/**
|
||||
* Synchronizes a list of "old" elements with a list of "new" elements so that the list
|
||||
* only contain equal elements.
|
||||
*
|
||||
* @param allOld list of old elements
|
||||
* @param allNew map of new elements (stored in key map)
|
||||
* @param selectKey generates a unique key from the element (will be called on old elements)
|
||||
* @param prepareNew prepares new elements (can be used to take over properties of old elements)
|
||||
*/
|
||||
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) {
|
||||
Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew))
|
||||
val remainingNew = allNew.toMutableMap()
|
||||
allOld.forEach { old ->
|
||||
val key = selectKey(old)
|
||||
val matchingNew = remainingNew[key]
|
||||
if (matchingNew != null) {
|
||||
// keep this old item, but maybe update it
|
||||
matchingNew.id = old.id // identity is proven by key
|
||||
prepareNew(matchingNew, old)
|
||||
|
||||
if (matchingNew != old)
|
||||
update(matchingNew)
|
||||
|
||||
// remove from remainingNew
|
||||
remainingNew -= key
|
||||
} else {
|
||||
// this old item is not present anymore, delete it
|
||||
delete(old)
|
||||
}
|
||||
}
|
||||
insert(remainingNew.values.toList())
|
||||
}
|
||||
|
||||
}
|
||||
28
app/src/main/java/at/bitfire/davdroid/model/HomeSet.kt
Normal file
28
app/src/main/java/at/bitfire/davdroid/model/HomeSet.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "homeset",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
// index by service; no duplicate URLs per service
|
||||
Index("serviceId", "url", unique = true)
|
||||
]
|
||||
)
|
||||
data class HomeSet(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long,
|
||||
|
||||
var serviceId: Long,
|
||||
var url: HttpUrl,
|
||||
|
||||
var privBind: Boolean = true,
|
||||
|
||||
var displayName: String? = null
|
||||
): IdEntity()
|
||||
21
app/src/main/java/at/bitfire/davdroid/model/HomeSetDao.kt
Normal file
21
app/src/main/java/at/bitfire/davdroid/model/HomeSetDao.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface HomeSetDao: SyncableDao<HomeSet> {
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun getBindableByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(homeSet: HomeSet): Long
|
||||
|
||||
|
||||
}
|
||||
5
app/src/main/java/at/bitfire/davdroid/model/IdEntity.kt
Normal file
5
app/src/main/java/at/bitfire/davdroid/model/IdEntity.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
abstract class IdEntity {
|
||||
abstract var id: Long
|
||||
}
|
||||
28
app/src/main/java/at/bitfire/davdroid/model/Service.kt
Normal file
28
app/src/main/java/at/bitfire/davdroid/model/Service.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "service",
|
||||
indices = [
|
||||
// only one service per type and account
|
||||
Index("accountName", "type", unique = true)
|
||||
])
|
||||
data class Service(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long,
|
||||
|
||||
var accountName: String,
|
||||
var type: String,
|
||||
|
||||
var principal: HttpUrl?
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val TYPE_CALDAV = "caldav"
|
||||
const val TYPE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
}
|
||||
35
app/src/main/java/at/bitfire/davdroid/model/ServiceDao.kt
Normal file
35
app/src/main/java/at/bitfire/davdroid/model/ServiceDao.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface ServiceDao {
|
||||
|
||||
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getByAccountAndType(accountName: String, type: String): Service?
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getIdByAccountAndType(accountName: String, type: String): Long?
|
||||
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
fun get(id: Long): Service?
|
||||
|
||||
@Query("SELECT * FROM service WHERE type=:type")
|
||||
fun getByType(type: String): List<Service>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(service: Service): Long
|
||||
|
||||
@Query("DELETE FROM service")
|
||||
fun deleteAll()
|
||||
|
||||
@Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)")
|
||||
fun deleteExceptAccounts(accountNames: Array<String>)
|
||||
|
||||
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
|
||||
fun renameAccount(oldName: String, newName: String)
|
||||
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncState(
|
||||
val type: Type,
|
||||
val value: String,
|
||||
val type: Type,
|
||||
val value: String,
|
||||
|
||||
/**
|
||||
* Whether this sync state occurred during an initial sync as described
|
||||
* in RFC 6578, which means the initial sync is not complete yet.
|
||||
*/
|
||||
var initialSync: Boolean? = null
|
||||
/**
|
||||
* Whether this sync state occurred during an initial sync as described
|
||||
* in RFC 6578, which means the initial sync is not complete yet.
|
||||
*/
|
||||
var initialSync: Boolean? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
18
app/src/main/java/at/bitfire/davdroid/model/SyncableDao.kt
Normal file
18
app/src/main/java/at/bitfire/davdroid/model/SyncableDao.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Update
|
||||
|
||||
interface SyncableDao<T: IdEntity> {
|
||||
|
||||
@Insert
|
||||
fun insert(items: List<T>)
|
||||
|
||||
@Update
|
||||
fun update(item: T)
|
||||
|
||||
@Delete
|
||||
fun delete(item: T)
|
||||
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.vcard4android.Contact
|
||||
|
||||
interface LocalAddress: LocalResource<Contact> {
|
||||
|
||||
fun resetDeleted()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.util.Base64
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* A local address book. Requires an own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book. These accounts are bound to a
|
||||
* DAVx5 main account.
|
||||
*/
|
||||
class LocalAddressBook(
|
||||
private val context: Context,
|
||||
account: Account,
|
||||
provider: ContentProviderClient?
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
companion object {
|
||||
|
||||
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(mainAccount, info.url.toString())
|
||||
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!accountManager.addAccountExplicitly(account, null, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
|
||||
// Android < 7 seems to lose the initial user data sometimes, so set it a second time
|
||||
// https://forums.bitfire.at/post/11644
|
||||
userData.keySet().forEach { key ->
|
||||
accountManager.setUserData(account, key, userData.getString(key))
|
||||
}
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
addressBook.readOnly = !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context)
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter { mainAccount == null || it.mainAccount == mainAccount }
|
||||
.toList()
|
||||
|
||||
fun accountName(mainAccount: Account, info: Collection): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
baos.write(info.url.hashCode())
|
||||
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
DavUtils.lastSegmentOfUrl(info.url)
|
||||
else
|
||||
it
|
||||
})
|
||||
sb.append(" (${mainAccount.name} $hash)")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun initialUserData(mainAccount: Account, url: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
fun mainAccount(context: Context, account: Account): Account =
|
||||
if (account.type == context.getString(R.string.account_type_address_book)) {
|
||||
val manager = AccountManager.get(context)
|
||||
Account(
|
||||
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME),
|
||||
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
)
|
||||
} else
|
||||
account
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
* and are affected by updates/deletes on generic members.
|
||||
*
|
||||
* For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
var includeGroups = true
|
||||
|
||||
private var _mainAccount: Account? = null
|
||||
var mainAccount: Account
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw IllegalStateException("No main account assigned to address book account")
|
||||
}
|
||||
}
|
||||
set(newMainAccount) {
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
|
||||
}
|
||||
|
||||
_mainAccount = newMainAccount
|
||||
}
|
||||
|
||||
var url: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
set(state) {
|
||||
syncState = state?.toString()?.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
/* operations on the collection (address book) itself */
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalContact.COLUMN_FLAGS, flags)
|
||||
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
|
||||
|
||||
if (includeGroups) {
|
||||
values.clear()
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider.delete(groupsSyncUri(),
|
||||
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
fun update(info: Collection) {
|
||||
val newAccountName = accountName(mainAccount, info)
|
||||
|
||||
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
|
||||
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
|
||||
val accountManager = AccountManager.get(context)
|
||||
val future = accountManager.renameAccount(account, newAccountName, null, null)
|
||||
account = future.result
|
||||
}
|
||||
|
||||
val nowReadOnly = !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
accountManager.removeAccount(account, null, null)
|
||||
}
|
||||
|
||||
|
||||
/* operations on members (contacts/groups) */
|
||||
|
||||
override fun findByName(name: String): LocalAddress? {
|
||||
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
return if (includeGroups)
|
||||
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
else
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDeleted() =
|
||||
if (includeGroups)
|
||||
findDeletedContacts() + findDeletedGroups()
|
||||
else
|
||||
findDeletedContacts()
|
||||
|
||||
fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null)
|
||||
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDirty() =
|
||||
if (includeGroups)
|
||||
findDirtyContacts() + findDirtyGroups()
|
||||
else
|
||||
findDirtyContacts()
|
||||
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
|
||||
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
|
||||
|
||||
override fun findDirtyWithoutNameOrUid() =
|
||||
if (includeGroups)
|
||||
findDirtyContactsWithoutNameOrUid() + findDirtyGroupsWithoutNameOrUid()
|
||||
else
|
||||
findDirtyContactsWithoutNameOrUid()
|
||||
private fun findDirtyContactsWithoutNameOrUid() = queryContacts(
|
||||
"${RawContacts.DIRTY} AND (${AndroidContact.COLUMN_FILENAME} IS NULL OR ${AndroidContact.COLUMN_UID} IS NULL)",
|
||||
null)
|
||||
private fun findDirtyGroupsWithoutNameOrUid() = queryGroups(
|
||||
"${Groups.DIRTY} AND (${AndroidGroup.COLUMN_FILENAME} IS NULL OR ${AndroidGroup.COLUMN_UID} IS NULL)",
|
||||
null)
|
||||
|
||||
override fun forgetETags() {
|
||||
if (includeGroups) {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidGroup.COLUMN_ETAG)
|
||||
provider!!.update(groupsSyncUri(), values, null, null)
|
||||
}
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidContact.COLUMN_ETAG)
|
||||
provider!!.update(rawContactsSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
|
||||
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
|
||||
* whose contact data checksum has not changed.
|
||||
* @return number of "really dirty" contacts
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
fun verifyDirty(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
|
||||
|
||||
var reallyDirty = 0
|
||||
for (contact in findDirtyContacts()) {
|
||||
val lastHash = contact.getLastHashCode()
|
||||
val currentHash = contact.dataHashCode()
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGroups)
|
||||
reallyDirty += findDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
fun getByGroupMembership(groupID: Long): List<LocalContact> {
|
||||
val ids = HashSet<Long>()
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
|
||||
null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
|
||||
return ids.map { findContactByID(it) }
|
||||
}
|
||||
|
||||
|
||||
/* special group operations */
|
||||
|
||||
/**
|
||||
* Finds the first group with the given title. If there is no group with this
|
||||
* title, a new group is created.
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
fun findOrCreateGroup(title: String): Long {
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
fun removeEmptyGroups() {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
|
||||
Logger.log.log(Level.FINE, "Deleting group", group)
|
||||
group.delete()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
255
app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt
Normal file
255
app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt
Normal file
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalCalendar private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
id: Long
|
||||
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timezone?.let { tzData ->
|
||||
try {
|
||||
val timeZone = DateUtils.parseVTimeZone(tzData)
|
||||
timeZone.timeZoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
|
||||
}
|
||||
} catch(e: IllegalArgumentException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = displayName ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return SyncState.fromString(cursor.getString(0))
|
||||
else
|
||||
null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_SYNC_STATE, state.toString())
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val event = localEvent.event!!
|
||||
val sequence = event.sequence
|
||||
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (localEvent.weAreOrganizer)
|
||||
event.sequence = sequence!! + 1
|
||||
dirty += localEvent
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findDirtyWithoutNameOrUid() =
|
||||
queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"(${Events._SYNC_ID} IS NULL OR ${Events.UID_2445} IS NULL)", null)
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
return provider.update(eventsSyncURI(), 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(eventsSyncURI(), 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.Operation(
|
||||
ContentProviderOperation.newDelete(eventsSyncURI())
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
|
||||
))
|
||||
}
|
||||
deleted = batch.commit()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(id.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
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.Operation(
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// completely remove deleted exception
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(id.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
// original event to DIRTY
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidCalendarFactory<LocalCalendar> {
|
||||
|
||||
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
|
||||
LocalCalendar(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +1,26 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
/**
|
||||
* This is an interface between the Syncer/SyncManager and a collection in the local storage.
|
||||
*
|
||||
* It defines operations that are used during sync for all sync data types.
|
||||
*/
|
||||
interface LocalCollection<out T: LocalResource> {
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
|
||||
/** ID of the collection in the database (corresponds to [at.bitfire.davdroid.db.Collection.id]) */
|
||||
val dbCollectionId: Long?
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
|
||||
var lastSyncState: SyncState?
|
||||
|
||||
/**
|
||||
* Whether the collection should be treated as read-only on sync.
|
||||
* Stops uploading dirty events (Server side changes are still downloaded).
|
||||
*/
|
||||
val readOnly: Boolean
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *deleted* by the user
|
||||
* or an app acting on their behalf.
|
||||
@@ -44,6 +37,17 @@ interface LocalCollection<out T: LocalResource> {
|
||||
*/
|
||||
fun findDirty(): List<T>
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which do not have a file name and/or UID, but
|
||||
* need one for synchronization.
|
||||
*
|
||||
* For instance, exceptions of recurring events are local resources but do not need their
|
||||
* own file name/UID because they're sent with the same UID as the main event.
|
||||
*
|
||||
* @return list of resources which need file name and UID for synchronization, but don't have both of them
|
||||
*/
|
||||
fun findDirtyWithoutNameOrUid(): List<T>
|
||||
|
||||
/**
|
||||
* Finds a local resource of this collection with a given file name. (File names are assigned
|
||||
* by the sync adapter.)
|
||||
@@ -53,8 +57,10 @@ interface LocalCollection<out T: LocalResource> {
|
||||
*/
|
||||
fun findByName(name: String): T?
|
||||
|
||||
|
||||
/**
|
||||
* Updates the flags value for entries which are not dirty.
|
||||
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
|
||||
* and have an [Events.ORIGINAL_ID] of null.
|
||||
*
|
||||
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
|
||||
*
|
||||
@@ -63,7 +69,8 @@ interface LocalCollection<out T: LocalResource> {
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes entries which are not dirty with a given flag combination.
|
||||
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
|
||||
* a given flag combination.
|
||||
*
|
||||
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
|
||||
* all entries with exactly this flag will be removed)
|
||||
@@ -78,4 +85,4 @@ interface LocalCollection<out T: LocalResource> {
|
||||
*/
|
||||
fun forgetETags()
|
||||
|
||||
}
|
||||
}
|
||||
253
app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
253
app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.UnknownProperties
|
||||
import at.bitfire.vcard4android.*
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
}
|
||||
|
||||
private val cachedGroupMemberships = HashSet<Long>()
|
||||
private val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val hashCode = dataHashCode()
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun populateData(mimeType: String, row: ContentValues) {
|
||||
when (mimeType) {
|
||||
CachedGroupMembership.CONTENT_ITEM_TYPE ->
|
||||
cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID)
|
||||
GroupMembership.CONTENT_ITEM_TYPE ->
|
||||
groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID)
|
||||
UnknownProperties.CONTENT_ITEM_TYPE ->
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
}
|
||||
}
|
||||
|
||||
override fun insertDataRows(batch: BatchOperation) {
|
||||
super.insertDataRows(batch)
|
||||
|
||||
contact!!.unknownProperties?.let { unknownProperties ->
|
||||
val op: BatchOperation.Operation
|
||||
val builder = ContentProviderOperation.newInsert(dataSyncURI())
|
||||
if (id == null)
|
||||
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
|
||||
else {
|
||||
op = BatchOperation.Operation(builder)
|
||||
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
|
||||
}
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(op)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates a hash code from the contact's data (VCard) and group memberships.
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
internal fun dataHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
|
||||
|
||||
// reset contact so that getContact() reads from database
|
||||
contact = null
|
||||
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = contact!!.hashCode()
|
||||
val groupHash = groupMemberships.hashCode()
|
||||
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
fun updateHashCode(batch: BatchOperation?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val values = ContentValues(1)
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
|
||||
if (batch == null)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
else {
|
||||
val builder = ContentProviderOperation
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValues(values)
|
||||
batch.enqueue(BatchOperation.Operation(builder))
|
||||
}
|
||||
}
|
||||
|
||||
fun getLastHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
|
||||
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: BatchOperation, groupID: Long) {
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
))
|
||||
groupMemberships += groupID
|
||||
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
cachedGroupMemberships += groupID
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
groupMemberships.clear()
|
||||
cachedGroupMemberships.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
|
||||
* whether a membership has been deleted/added when a raw contact is dirty.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
fun getCachedGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return cachedGroupMemberships
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact is member of.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
fun getGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return groupMemberships
|
||||
}
|
||||
|
||||
|
||||
// data rows
|
||||
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
|
||||
LocalContact(addressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
130
app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
130
app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.*
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import java.util.*
|
||||
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion)
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
private set
|
||||
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
var weAreOrganizer = true
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues) {
|
||||
super.populateEvent(row)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
event.uid = row.getAsString(Events.UID_2445)
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
super.buildEvent(recurrence, builder)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(Events.UID_2445, event.uid)
|
||||
.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)
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
if (uid == null)
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Events._SYNC_ID, newFileName)
|
||||
values.put(Events.UID_2445, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
|
||||
LocalEvent(calendar, values)
|
||||
}
|
||||
|
||||
}
|
||||
246
app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
246
app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.dav4jvm.Constants
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.util.*
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
const val COLUMN_FLAGS = Groups.SYNC4
|
||||
|
||||
/** marshaled list of member UIDs, as sent by server */
|
||||
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
|
||||
* are (if possible) applied, keeping cached memberships in sync.
|
||||
* @param addressBook address book to take groups from
|
||||
*/
|
||||
fun applyPendingMemberships(addressBook: LocalAddressBook) {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.groupsSyncUri(),
|
||||
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
|
||||
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val batch = BatchOperation(addressBook.provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
Constants.log.fine("Assigning members to group $id")
|
||||
|
||||
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val changeContactIDs = HashSet<Long>()
|
||||
|
||||
// delete all memberships and cached memberships for this group
|
||||
for (contact in addressBook.getByGroupMembership(id)) {
|
||||
contact.removeGroupMemberships(batch)
|
||||
changeContactIDs += contact.id!!
|
||||
}
|
||||
|
||||
// extract list of member UIDs
|
||||
val members = LinkedList<String>()
|
||||
val raw = cursor.getBlob(1)
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
parcel.readStringList(members)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
|
||||
// insert memberships
|
||||
for (uid in members) {
|
||||
Constants.log.fine("Assigning member: $uid")
|
||||
addressBook.findContactByUID(uid)?.let { member ->
|
||||
member.addToGroup(batch, id)
|
||||
changeContactIDs += member.id!!
|
||||
} ?: Constants.log.warning("Group member not found: $uid")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { addressBook.findContactByID(it) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
|
||||
// remove pending memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
members.writeStringList(contact!!.members)
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
|
||||
} finally {
|
||||
members.recycle()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
update(values)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Groups.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
this.eTag = eTag
|
||||
update(values)
|
||||
|
||||
// update cached group memberships
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
// delete cached group memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
|
||||
)
|
||||
))
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
fun markMembersDirty() {
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun groupSyncUri(): Uri {
|
||||
val id = requireNotNull(id)
|
||||
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of this group.
|
||||
* @return list of all members' raw contact IDs
|
||||
* @throws RemoteException on contact provider errors
|
||||
*/
|
||||
internal fun getMembers(): List<Long> {
|
||||
val id = requireNotNull(id)
|
||||
val members = LinkedList<Long>()
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(Data.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
members += cursor.getLong(0)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidGroupFactory<LocalGroup> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
|
||||
LocalGroup(addressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
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`.
|
||||
*/
|
||||
val fileName: String?
|
||||
var eTag: String?
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Generates a new UID and file name and assigns them to this resource. Typically used
|
||||
* before uploading a resource which has just been created locally.
|
||||
*/
|
||||
fun assignNameAndUID()
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
*
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(eTag: String?)
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
}
|
||||
101
app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
101
app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.ical4android.AndroidTask
|
||||
import at.bitfire.ical4android.AndroidTaskFactory
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.*
|
||||
|
||||
class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(taskList: AndroidTaskList<*>, 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: ContentProviderOperation.Builder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(Tasks._UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
|
||||
task!!.uid = uid
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
173
app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt
Normal file
173
app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt
Normal file
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: TaskProvider,
|
||||
id: Long
|
||||
): AndroidTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
fun tasksProviderAvailable(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
|
||||
else
|
||||
try {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use {
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// couldn't acquire task provider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
values.put(TaskLists.VISIBLE, 1)
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
try {
|
||||
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
client?.use {
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
|
||||
}
|
||||
} finally {
|
||||
client?.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
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.sequence = sequence + 1
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findDirtyWithoutNameOrUid() =
|
||||
queryTasks("${Tasks._DIRTY} AND (${Tasks._SYNC_ID} IS NULL OR ${Tasks._UID} IS NULL)", null)
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalTask.COLUMN_FLAGS, flags)
|
||||
return provider.client.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.client.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
|
||||
LocalTaskList(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
class AccountSettings(
|
||||
val context: Context,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 10
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val WIFI_ONLY_DEFAULT = false
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
|
||||
/** Time range limitation to the past [in days]. Values:
|
||||
*
|
||||
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
* - <0 (typically -1): no limit
|
||||
* - n>0: entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/**
|
||||
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
|
||||
* Value can be null (no default alarm) or an integer (default alarm shall be created this
|
||||
* number of minutes before the event/task).
|
||||
*/
|
||||
const val KEY_DEFAULT_ALARM = "default_alarm"
|
||||
|
||||
/* Whether DAVx5 sets the local calendar color to the value from service DB at every sync
|
||||
value = null (not existing) true (default)
|
||||
"0" false */
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/* Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = null (not existing) false (default)
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
value = null (not existing) groups as separate VCards (default)
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
fun initialUserData(credentials: Credentials): Bundle {
|
||||
val bundle = Bundle(2)
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword ->
|
||||
bundle.putString(KEY_USERNAME, credentials.userName)
|
||||
Credentials.Type.ClientCertificate ->
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val settings = Settings.getInstance(context)
|
||||
|
||||
init {
|
||||
synchronized(AccountSettings::class.java) {
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
}
|
||||
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION)
|
||||
update(version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
fun credentials() = Credentials(
|
||||
accountManager.getUserData(account, KEY_USERNAME),
|
||||
accountManager.getPassword(account),
|
||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
|
||||
)
|
||||
|
||||
fun credentials(credentials: Credentials) {
|
||||
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
|
||||
accountManager.setPassword(account, credentials.password)
|
||||
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0)
|
||||
return null
|
||||
|
||||
return if (ContentResolver.getSyncAutomatically(account, authority))
|
||||
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
|
||||
else
|
||||
SYNC_INTERVAL_MANUALLY
|
||||
}
|
||||
|
||||
fun setSyncInterval(authority: String, seconds: Long) {
|
||||
if (seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
} else {
|
||||
ContentResolver.setSyncAutomatically(account, authority, true)
|
||||
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
return if (strDays != null) {
|
||||
val days = strDays.toInt()
|
||||
if (days < 0)
|
||||
null
|
||||
else
|
||||
days
|
||||
} else
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
/**
|
||||
* Takes the default alarm setting (in this order) from
|
||||
*
|
||||
* 1. the local account settings
|
||||
* 2. the settings provider (unless the value is -1 there).
|
||||
*
|
||||
* @return A default reminder shall be created this number of minutes before the start of every
|
||||
* non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun getDefaultAlarm() =
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 }
|
||||
|
||||
/**
|
||||
* Sets the default alarm value in the local account settings, if the new value differs
|
||||
* from the value of the settings provider. If the new value is the same as the value of
|
||||
* the settings provider, the local setting will be deleted, so that the settings provider
|
||||
* value applies.
|
||||
*
|
||||
* @param minBefore The number of minutes a default reminder shall be created before the
|
||||
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun setDefaultAlarm(minBefore: Int?) =
|
||||
accountManager.setUserData(account, KEY_DEFAULT_ALARM,
|
||||
if (minBefore == settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
|
||||
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false
|
||||
else
|
||||
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
fun getEventColors() = if (settings.has(KEY_EVENT_COLORS))
|
||||
settings.getBoolean(KEY_EVENT_COLORS) ?: false
|
||||
else
|
||||
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
|
||||
fun setEventColors(useColors: Boolean) =
|
||||
accountManager.setUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
fun getGroupMethod(): GroupMethod {
|
||||
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
|
||||
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
|
||||
if (name != null)
|
||||
try {
|
||||
return GroupMethod.valueOf(name)
|
||||
}
|
||||
catch (e: IllegalArgumentException) {
|
||||
}
|
||||
return GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
fun setGroupMethod(method: GroupMethod) {
|
||||
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(this)
|
||||
|
||||
Logger.log.info("Account version update successful")
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Task synchronization now handles alarms, categories, relations and unknown properties.
|
||||
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
|
||||
*
|
||||
* Also update the allowed reminder types for calendars.
|
||||
**/
|
||||
private fun update_9_10() {
|
||||
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
|
||||
val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), account)
|
||||
val emptyETag = ContentValues(1)
|
||||
emptyETag.putNull(LocalTask.COLUMN_ETAG)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account),
|
||||
AndroidCalendar.calendarBaseValues, null, null)
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
|
||||
* Disable it on those accounts for the future.
|
||||
*/
|
||||
private fun update_8_9() {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) {
|
||||
Logger.log.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
@SuppressLint("Recycle")
|
||||
/**
|
||||
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
|
||||
* SEQUENCE and should not be used for the eTag.
|
||||
*/
|
||||
private fun update_7_8() {
|
||||
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
|
||||
// ETag is now in sync_version instead of sync1
|
||||
// UID is now in _uid instead of sync2
|
||||
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account),
|
||||
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
|
||||
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.type, account.name), null)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val eTag = cursor.getString(1)
|
||||
val uid = cursor.getString(2)
|
||||
val values = ContentValues(4)
|
||||
values.put(TaskContract.Tasks._UID, uid)
|
||||
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
|
||||
values.putNull(TaskContract.Tasks.SYNC1)
|
||||
values.putNull(TaskContract.Tasks.SYNC2)
|
||||
Logger.log.log(Level.FINER, "Updating task $id", values)
|
||||
provider.client.update(
|
||||
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
|
||||
values, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
try {
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
} finally {
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
// update allowed WiFi settings key
|
||||
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID)
|
||||
accountManager.setUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle", "ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
// don't run syncs during the migration
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
// get previous address book settings (including URL)
|
||||
val raw = ContactsContract.SyncState.get(provider, account)
|
||||
if (raw == null)
|
||||
Logger.log.info("No contacts sync state, ignoring account")
|
||||
else {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()!!
|
||||
val url = params.getString("url")?.let { HttpUrl.parse(it) }
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
Logger.log.info("Moving contacts from $account to $addressBookAccount")
|
||||
val newAccount = ContentValues(2)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
|
||||
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
|
||||
newAccount,
|
||||
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
|
||||
arrayOf(account.name, account.type))
|
||||
Logger.log.info("$affected contacts moved to new address book")
|
||||
}
|
||||
|
||||
ContactsContract.SyncState.set(provider, account, null)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
// update version number so that further syncs don't repeat the migration
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
|
||||
|
||||
// request sync of new address book account
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
|
||||
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
|
||||
/* Android 7.1.1 OpenTasks fix */
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
PackageChangedReceiver.updateTaskSync(context)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 2 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
open class DefaultsProvider(
|
||||
private val allowOverride: Boolean = true
|
||||
): SettingsProvider {
|
||||
|
||||
open val booleanDefaults = mapOf(
|
||||
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT),
|
||||
Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT)
|
||||
)
|
||||
|
||||
open val intDefaults = mapOf(
|
||||
Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
)
|
||||
|
||||
open val longDefaults = mapOf<String, Long>()
|
||||
|
||||
open val stringDefaults = mapOf(
|
||||
Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
)
|
||||
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
}
|
||||
|
||||
|
||||
private fun hasKey(key: String) =
|
||||
booleanDefaults.containsKey(key) ||
|
||||
intDefaults.containsKey(key) ||
|
||||
longDefaults.containsKey(key) ||
|
||||
stringDefaults.containsKey(key)
|
||||
|
||||
override fun has(key: String): Pair<Boolean, Boolean> {
|
||||
val has = hasKey(key)
|
||||
return Pair(has, allowOverride || !has)
|
||||
}
|
||||
|
||||
|
||||
override fun getBoolean(key: String) =
|
||||
Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key))
|
||||
|
||||
override fun getInt(key: String) =
|
||||
Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key))
|
||||
|
||||
override fun getLong(key: String) =
|
||||
Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key))
|
||||
|
||||
override fun getString(key: String) =
|
||||
Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key))
|
||||
|
||||
|
||||
override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key))
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) = false
|
||||
override fun putInt(key: String, value: Int?) = false
|
||||
override fun putLong(key: String, value: Long?) = false
|
||||
override fun putString(key: String, value: String?) = false
|
||||
|
||||
override fun remove(key: String) = false
|
||||
|
||||
|
||||
class Factory : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(DefaultsProvider())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface ISettingsProviderFactory {
|
||||
|
||||
fun getProviders(context: Context): List<SettingsProvider>
|
||||
|
||||
}
|
||||
199
app/src/main/java/at/bitfire/davdroid/settings/Settings.kt
Normal file
199
app/src/main/java/at/bitfire/davdroid/settings/Settings.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class Settings(
|
||||
appContext: Context
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
// settings keys and default values
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false
|
||||
const val OVERRIDE_PROXY = "override_proxy"
|
||||
const val OVERRIDE_PROXY_DEFAULT = false
|
||||
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
|
||||
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
|
||||
private var singleton: Settings? = null
|
||||
|
||||
fun getInstance(context: Context): Settings {
|
||||
singleton?.let { return it }
|
||||
|
||||
val newInstance = Settings(context.applicationContext)
|
||||
singleton = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val providers = LinkedList<SettingsProvider>()
|
||||
private val observers = LinkedList<WeakReference<OnChangeListener>>()
|
||||
|
||||
init {
|
||||
val factories = ServiceLoader.load(ISettingsProviderFactory::class.java)
|
||||
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
|
||||
factories.forEach { factory ->
|
||||
providers.addAll(factory.getProviders(appContext))
|
||||
}
|
||||
}
|
||||
|
||||
fun forceReload() {
|
||||
providers.forEach {
|
||||
it.forceReload()
|
||||
}
|
||||
onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
fun addOnChangeListener(observer: OnChangeListener) {
|
||||
synchronized(this) {
|
||||
observers += WeakReference(observer)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeOnChangeListener(observer: OnChangeListener) {
|
||||
synchronized(this) {
|
||||
observers.removeAll { it.get() == null || it.get() == observer }
|
||||
}
|
||||
}
|
||||
|
||||
fun onSettingsChanged() {
|
||||
synchronized(this) {
|
||||
observers.mapNotNull { it.get() }.forEach {
|
||||
it.onSettingsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*** SETTINGS ACCESS ***/
|
||||
|
||||
fun has(key: String): Boolean {
|
||||
Logger.log.fine("Looking for setting $key")
|
||||
var result = false
|
||||
for (provider in providers)
|
||||
try {
|
||||
val (value, further) = provider.has(key)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further")
|
||||
if (value) {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
if (!further)
|
||||
break
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looking for setting $key -> $result")
|
||||
return result
|
||||
}
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SettingsProvider) -> Pair<T?, Boolean>): T? {
|
||||
Logger.log.fine("Looking up setting $key")
|
||||
var result: T? = null
|
||||
for (provider in providers)
|
||||
try {
|
||||
val (value, further) = reader(provider)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further")
|
||||
value?.let { result = it }
|
||||
if (!further)
|
||||
break
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looked up setting $key -> $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getBoolean(key: String) =
|
||||
getValue(key) { provider -> provider.getBoolean(key) }
|
||||
|
||||
fun getInt(key: String) =
|
||||
getValue(key) { provider -> provider.getInt(key) }
|
||||
|
||||
fun getLong(key: String) =
|
||||
getValue(key) { provider -> provider.getLong(key) }
|
||||
|
||||
fun getString(key: String) =
|
||||
getValue(key) { provider -> provider.getString(key) }
|
||||
|
||||
|
||||
fun isWritable(key: String): Boolean {
|
||||
for (provider in providers) {
|
||||
val (value, further) = provider.isWritable(key)
|
||||
if (value)
|
||||
return true
|
||||
if (!further)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean {
|
||||
Logger.log.fine("Trying to write setting $key = $value")
|
||||
for (provider in providers) {
|
||||
val (writable, further) = provider.isWritable(key)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further")
|
||||
if (writable)
|
||||
return try {
|
||||
writer(provider)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e)
|
||||
false
|
||||
}
|
||||
if (!further)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value) { provider -> provider.putBoolean(key, value) }
|
||||
|
||||
fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value) { provider -> provider.putInt(key, value) }
|
||||
|
||||
fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value) { provider -> provider.putLong(key, value) }
|
||||
|
||||
fun putString(key: String, value: String?) =
|
||||
putValue(key, value) { provider -> provider.putString(key, value) }
|
||||
|
||||
fun remove(key: String): Boolean {
|
||||
var deleted = false
|
||||
providers.forEach { deleted = deleted || it.remove(key) }
|
||||
return deleted
|
||||
}
|
||||
|
||||
|
||||
interface OnChangeListener {
|
||||
/**
|
||||
* Will be called when something has changed in a [SettingsProvider].
|
||||
* Runs in worker thread!
|
||||
*/
|
||||
@WorkerThread
|
||||
fun onSettingsChanged()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
interface SettingsProvider {
|
||||
|
||||
fun forceReload()
|
||||
fun close()
|
||||
|
||||
fun has(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
fun getBoolean(key: String): Pair<Boolean?, Boolean>
|
||||
fun getInt(key: String): Pair<Int?, Boolean>
|
||||
fun getLong(key: String): Pair<Long?, Boolean>
|
||||
fun getString(key: String): Pair<String?, Boolean>
|
||||
|
||||
fun isWritable(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?): Boolean
|
||||
fun putInt(key: String, value: Int?): Boolean
|
||||
fun putLong(key: String, value: Long?): Boolean
|
||||
fun putString(key: String, value: String?): Boolean
|
||||
|
||||
fun remove(key: String): Boolean
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
|
||||
class SharedPreferencesProvider(
|
||||
val context: Context
|
||||
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
companion object {
|
||||
private const val META_VERSION = "version"
|
||||
private const val CURRENT_VERSION = 0
|
||||
}
|
||||
|
||||
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
init {
|
||||
val meta = context.getSharedPreferences("meta", MODE_PRIVATE)
|
||||
val version = meta.getInt(META_VERSION, -1)
|
||||
if (version == -1) {
|
||||
// first call, check whether to migrate from SQLite database (DAVdroid <1.9)
|
||||
firstCall(context)
|
||||
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
|
||||
}
|
||||
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
Settings.getInstance(context).onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
override fun has(key: String) =
|
||||
Pair(preferences.contains(key), true)
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): Pair<T?, Boolean> {
|
||||
if (preferences.contains(key))
|
||||
return Pair(
|
||||
try { reader(preferences) } catch(e: ClassCastException) { null },
|
||||
true)
|
||||
|
||||
return Pair(null, true)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
|
||||
|
||||
override fun getInt(key: String): Pair<Int?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getLong(key: String): Pair<Long?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getString(key: String): Pair<String?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) }
|
||||
|
||||
|
||||
override fun isWritable(key: String) =
|
||||
Pair(first = true, second = true)
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
|
||||
return if (value == null)
|
||||
remove(key)
|
||||
else {
|
||||
Logger.log.fine("Writing setting $key = $value")
|
||||
val edit = preferences.edit()
|
||||
writer(edit, value)
|
||||
edit.apply()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value) { editor, v -> editor.putBoolean(key, v) }
|
||||
|
||||
override fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value) { editor, v -> editor.putInt(key, v) }
|
||||
|
||||
override fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value) { editor, v -> editor.putLong(key, v) }
|
||||
|
||||
override fun putString(key: String, value: String?) =
|
||||
putValue(key, value) { editor, v -> editor.putString(key, v) }
|
||||
|
||||
override fun remove(key: String): Boolean {
|
||||
Logger.log.fine("Removing setting $key")
|
||||
preferences.edit()
|
||||
.remove(key)
|
||||
.apply()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
private fun firstCall(context: Context) {
|
||||
// remove possible artifacts from DAVdroid <1.9
|
||||
val edit = preferences.edit()
|
||||
edit.remove("override_proxy")
|
||||
edit.remove("proxy_host")
|
||||
edit.remove("proxy_port")
|
||||
edit.remove("log_to_external_storage")
|
||||
edit.apply()
|
||||
|
||||
// open ServiceDB to upgrade it and possibly migrate settings
|
||||
AppDatabase.getInstance(context)
|
||||
}
|
||||
|
||||
|
||||
class Factory : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user