diff --git a/.github/workflows/build-kdoc.yml b/.github/workflows/build-kdoc.yml index ceecf6f3..325b2dd2 100644 --- a/.github/workflows/build-kdoc.yml +++ b/.github/workflows/build-kdoc.yml @@ -1,29 +1,39 @@ -name: Build KDoc +name: Build and publish KDoc on: push: branches: [main] + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + jobs: build: - name: Build and publish KDoc runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: - submodules: true - - uses: actions/setup-java@v2 - with: - distribution: 'temurin' - java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 - name: Build KDoc - run: ./gradlew dokkaHtml - - name: Publish KDoc - if: success() - uses: crazy-max/ghaction-github-pages@v2.5.0 + run: ./gradlew --no-configuration-cache dokkaHtml + + - uses: actions/upload-pages-artifact@v3 with: - target_branch: gh-pages - build_dir: build/dokka/html - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + path: lib/build/dokka/html + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 9be6f6dc..00000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,74 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - - cron: '39 11 * * 0' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependent-issues.yml b/.github/workflows/dependent-issues.yml new file mode 100644 index 00000000..62b122fd --- /dev/null +++ b/.github/workflows/dependent-issues.yml @@ -0,0 +1,55 @@ +name: Dependent Issues + +on: + issues: + types: + - opened + - edited + - closed + - reopened + pull_request_target: + types: + - opened + - edited + - closed + - reopened + # Makes sure we always add status check for PRs. Useful only if + # this action is required to pass before merging. Otherwise, it + # can be removed. + - synchronize + + # Schedule a daily check. Useful if you reference cross-repository + # issues or pull requests. Otherwise, it can be removed. + schedule: + - cron: '12 9 * * *' + +permissions: write-all + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: z0al/dependent-issues@v1 + env: + # (Required) The token to use to make API calls to GitHub. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # (Optional) The token to use to make API calls to GitHub for remote repos. + GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }} + + with: + # (Optional) The label to use to mark dependent issues + # label: dependent + + # (Optional) Enable checking for dependencies in issues. + # Enable by setting the value to "on". Default "off" + check_issues: on + + # (Optional) A comma-separated list of keywords. Default + # "depends on, blocked by" + keywords: depends on, blocked by + + # (Optional) A custom comment body. It supports `{{ dependencies }}` token. + comment: > + This PR/issue depends on: + + {{ dependencies }} diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 9f93759a..2d0d6770 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -1,68 +1,104 @@ name: Development tests on: push jobs: + compile: + name: Compile and cache + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + # See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information + - uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch + with: + cache-encryption-key: ${{ secrets.gradle_encryption_key }} + gradle-home-cache-cleanup: true # clean up unused files + dependency-graph: generate-and-submit # submit Github Dependency Graph info + + - run: ./gradlew --build-cache --configuration-cache compileDebugSources + test: + needs: compile name: Tests without emulator runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: - submodules: true - - uses: actions/setup-java@v2 + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 with: - distribution: 'temurin' - java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + cache-encryption-key: ${{ secrets.gradle_encryption_key }} + cache-read-only: true - - name: Check - run: ./gradlew check - - name: Archive results - uses: actions/upload-artifact@v2 - with: - name: test-results - path: | - build/outputs/lint* - build/reports + - name: Run lint and unit tests + run: ./gradlew --build-cache --configuration-cache lintDebug testDebugUnitTest test_on_emulator: + needs: compile name: Tests with emulator - runs-on: privileged - container: - image: ghcr.io/bitfireat/docker-android-ci:main - options: --privileged - env: - ANDROID_HOME: /sdk - ANDROID_AVD_HOME: /root/.android/avd + runs-on: ubuntu-latest + strategy: + matrix: + api-level: [ 31 ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 with: - submodules: true - - uses: gradle/wrapper-validation-action@v1 + cache-encryption-key: ${{ secrets.gradle_encryption_key }} + cache-read-only: true + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - - name: Cache APKs and gradle dependencies - uses: actions/cache@v2 + - name: Cache AVD and APKs + uses: actions/cache@v4 + id: avd-cache with: - key: ${{ runner.os }}-1 path: | + ~/.android/avd/* + ~/.android/adb* ~/.apk - ~/.gradle/caches - ~/.gradle/wrapper + key: avd-${{ matrix.api-level }} - - name: Start emulator - run: start-emulator.sh - - name: Install task apps - run: | - mkdir .apk && cd .apk - wget -cq -O org.dmfs.tasks.apk https://f-droid.org/archive/org.dmfs.tasks_80800.apk && adb install org.dmfs.tasks.apk - wget -cq -O org.tasks.apk https://f-droid.org/archive/org.tasks_120400.apk && adb install org.tasks.apk - wget -cq -O at.techbee.jtx.apk https://f-droid.org/archive/at.techbee.jtx_100140002.apk && adb install at.techbee.jtx.apk - cd .. - - name: Run connected tests - run: ./gradlew connectedCheck -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.FlakyTest - - name: Archive results - uses: actions/upload-artifact@v2 + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 with: - name: test-results - path: | - build/reports + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Install task apps and run tests + uses: reactivecircus/android-emulator-runner@v2 + env: + version_at_techbee_jtx: v2.9.0 + version_org_tasks: 131104 + version_org_dmfs_tasks: 82200 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: | + mkdir .apk && cd .apk + (wget -cq -O org.dmfs.tasks.apk https://f-droid.org/repo/org.dmfs.tasks_${{ env.version_org_dmfs_tasks }}.apk || wget -cq -O org.dmfs.tasks.apk https://f-droid.org/archive/org.dmfs.tasks_${{ env.version_org_dmfs_tasks }}.apk) && adb install org.dmfs.tasks.apk + (wget -cq -O org.tasks.apk https://f-droid.org/repo/org.tasks_${{ env.version_org_tasks }}.apk || wget -cq -O org.tasks.apk https://f-droid.org/archive/org.tasks_${{ env.version_org_tasks }}.apk) && adb install org.tasks.apk + (wget -cq -O at.techbee.jtx.apk https://github.com/TechbeeAT/jtxBoard/releases/download/${{ env.version_at_techbee_jtx }}/jtxBoard-${{ env.version_at_techbee_jtx }}.apk) && adb install at.techbee.jtx.apk + cd .. + ./gradlew --build-cache --configuration-cache connectedCheck -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.FlakyTest diff --git a/.gitignore b/.gitignore index 291ae9be..bdcd7df9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,9 @@ # Files for the Dalvik VM *.dex -# Java class files +# Java/Kotlin files *.class +.kotlin/ # Generated files bin/ diff --git a/.idea/copyright/GPL.xml b/.idea/copyright/GPL.xml new file mode 100644 index 00000000..97e8cdf3 --- /dev/null +++ b/.idea/copyright/GPL.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 00000000..df54724c --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index 40c6495e..125cb86a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,14 +1,4 @@ -# This is the list of significant contributors to ical4android. -# -# This does not necessarily list everyone who has contributed work. -# To see the full list of contributors, see the revision history in -# source control. +You can view the list of people who have contributed to the code base in the version control history: +https://github.com/bitfireAT/ical4android/graphs/contributors -Initial contributor: - -* Ricki Hirner (bitfire.at) - - -Further contributor: - -* Patrick Lang (techbee.at): jtx Board integration +Every contribution is welcome. There are many other forms of contributing besides writing code! diff --git a/README.md b/README.md index f0c28ddb..5666f075 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Development tests](https://github.com/bitfireAT/ical4android/actions/workflows/test-dev.yml/badge.svg)](https://github.com/bitfireAT/ical4android/actions/workflows/test-dev.yml) [![Documentation](https://img.shields.io/badge/documentation-kdoc-brightgreen)](https://bitfireat.github.io/ical4android/) +[![Latest Version](https://img.shields.io/jitpack/version/com.github.bitfireAT/ical4android)](https://jitpack.io/#bitfireAT/ical4android) # ical4android @@ -32,17 +33,34 @@ by Google LLC. Android is a trademark of Google LLC._ ## How to use -You can use ical4android as a git submodule or using [jitpack.io](https://jitpack.io/#bitfireAT/ical4android): - +1. Add the [jitpack.io](https://jitpack.io) repository to your project's level `build.gradle`: + ```groovy allprojects { repositories { - maven { url 'https://jitpack.io' } + // ... more repos + maven { url "https://jitpack.io" } } } + ``` + or if you are using `settings.gradle`: + ```groovy + dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + // ... more repos + maven { url "https://jitpack.io" } + } + } + ``` +2. Add the dependency to your module's `build.gradle` file: + ```groovy dependencies { - implementation 'com.github.bitfireAT:ical4android:' // see tags for latest version, like 1.0, or use the latest commit ID from main branch - //implementation 'com.github.bitfireAT:ical4android:main-SNAPSHOT' // use it only for testing because it doesn't generate reproducible builds + implementation 'com.github.bitfireAT:ical4android:' } + ``` + +To view the available gradle tasks for the library: `./gradlew ical4android:tasks` +(the `ical4android` module is defined in `settings.gradle`). ## Contact diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 189c51fe..00000000 --- a/build.gradle +++ /dev/null @@ -1,123 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -buildscript { - ext.versions = [ - kotlin: '1.7.21', - dokka: '1.7.20', - ical4j: '3.2.19', - // latest Apache Commons versions that don't require Java 8 (Android 7) - commonsIO: '2.6' - ] - - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:8.10.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" - classpath "org.jetbrains.dokka:dokka-gradle-plugin:${versions.dokka}" - } -} - -repositories { - google() - mavenCentral() -} - -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' -apply plugin: 'org.jetbrains.dokka' -apply plugin: 'maven-publish' - -group = 'com.github.tasks' -version = '1.0' - -android { - compileSdkVersion 33 - buildToolsVersion '33.0.0' - - defaultConfig { - minSdkVersion 21 // Android 5.0 - targetSdkVersion 33 // Android 13 - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField "String", "version_ical4j", "\"${versions.ical4j}\"" - } - - namespace 'at.bitfire.ical4android' - - compileOptions { - // ical4j >= 3.x uses the Java 8 Time API - coreLibraryDesugaringEnabled true - - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - packagingOptions { - resources { - excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/*.md'] - } - } - lint { - disable 'AllowBackup', 'InvalidPackage' - } - - sourceSets { - main.java.srcDirs = [ "src/main/java", "opentasks-contract/src/main/java" ] - } - - buildFeatures { - buildConfig = true - } -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}" - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' // 2.0.0 produces "Unsupported desugared library configuration version, please upgrade the D8/R8 compiler." - - api("org.mnode.ical4j:ical4j:${versions.ical4j}") { - // exclude modules which are in conflict with system libraries - exclude group: 'commons-logging' - exclude group: 'org.json', module: 'json' - // exclude groovy because we don't need it - exclude group: 'org.codehaus.groovy', module: 'groovy' - exclude group: 'org.codehaus.groovy', module: 'groovy-dateutil' - } - // ical4j requires newer Apache Commons libraries, which require Java8. Force latest Java7 versions. - // noinspection GradleDependency - api("org.apache.commons:commons-collections4:4.2") - // noinspection GradleDependency - api("org.apache.commons:commons-lang3:3.8.1") - - // noinspection GradleDependency - implementation "commons-io:commons-io:${versions.commonsIO}" - - implementation 'org.slf4j:slf4j-jdk14:2.0.3' - implementation 'androidx.core:core-ktx:1.9.0' - - androidTestImplementation 'androidx.test:core:1.4.0' - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' - testImplementation 'junit:junit:4.13.2' -} - -publishing { - publications { - release(MavenPublication) { - groupId = 'com.github.tasks' - artifactId = 'ical4android' - version = '1.0' - afterEvaluate { - from components.release - } - } - } -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/AndroidTaskFactory.kt b/build.gradle.kts similarity index 58% rename from src/main/java/at/bitfire/ical4android/AndroidTaskFactory.kt rename to build.gradle.kts index 41d3f70a..ecb26b39 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidTaskFactory.kt +++ b/build.gradle.kts @@ -2,12 +2,11 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.ical4android - -import android.content.ContentValues - -interface AndroidTaskFactory { - - fun fromProvider(taskList: AndroidTaskList, values: ContentValues): T - +plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.dokka) apply false } + +group = "at.bitfire" +version = System.getenv("GIT_COMMIT") diff --git a/gradle.properties b/gradle.properties index d9cf55df..20aaaa86 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,14 @@ -org.gradle.jvmargs=-Xmx1536M +# [https://developer.android.com/build/optimize-your-build#optimize] +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1g +org.gradle.parallel=true + +# configuration cache [https://developer.android.com/build/optimize-your-build#use-the-configuration-cache-experimental] +org.gradle.unsafe.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn + +# https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching=true + +# Android android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..3be8ef8d --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +agp = "8.9.2" +android-desugar = "2.1.5" +androidx-core = "1.16.0" +androidx-test-rules = "1.6.1" +androidx-test-runner = "1.6.2" +dokka = "1.9.20" +# noinspection GradleDependency +ical4j = "3.2.19" # final version; update to 4.x will require much work +junit = "4.13.2" +kotlin = "2.1.20" +mockk = "1.14.2" +slf4j = "2.0.17" + +[libraries] +android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugar" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +ical4j = { module = "org.mnode.ical4j:ical4j", version.ref = "ical4j" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +slf4j-jdk = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4j" } + +[plugins] +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..a4b76b95 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 12929abc..37f853b1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Mon Jun 02 17:38:23 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..f5feea6d 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,69 +15,104 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +122,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f9..9b42019c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 00000000..d62dbac2 --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,5 @@ +jdk: + - openjdk21 +before_install: + - sdk install java 21.0.4-tem + - sdk use java 21.0.4-tem \ No newline at end of file diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 00000000..cb4cadfc --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,105 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.dokka) + `maven-publish` +} + +android { + compileSdk = 35 + + namespace = "at.bitfire.ical4android" + + defaultConfig { + minSdk = 23 // Android 6 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "version_ical4j", "\"${libs.versions.ical4j.get()}\"") + + aarMetadata { + minCompileSdk = 29 + } + + // These ProGuard/R8 rules will be included in the final APK. + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + // ical4j >= 3.x uses the Java 8 Time API + isCoreLibraryDesugaringEnabled = true + } + kotlin { + jvmToolchain(21) + } + + buildFeatures.buildConfig = true + + sourceSets["main"].apply { + kotlin { + srcDir("${projectDir}/src/main/kotlin") + } + java { + srcDir("${rootDir}/opentasks-contract/src/main/java") + } + } + + packaging { + resources { + excludes += listOf("META-INF/DEPENDENCIES", "META-INF/LICENSE", "META-INF/*.md") + } + } + + buildTypes { + release { + // Android libraries shouldn't be minified: + // https://developer.android.com/studio/projects/android-library#Considerations + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + + lint { + disable += listOf("AllowBackup", "InvalidPackage") + } + + publishing { + // Configure publish variant + singleVariant("release") { + withSourcesJar() + } + } +} + +publishing { + // Configure publishing data + publications { + register("release", MavenPublication::class.java) { + groupId = "com.github.bitfireAT" + artifactId = "ical4android" + version = System.getenv("GIT_COMMIT") + + afterEvaluate { + from(components["release"]) + } + } + } +} + +dependencies { + implementation(libs.kotlin.stdlib) + coreLibraryDesugaring(libs.android.desugaring) + + implementation(libs.androidx.core) + api(libs.ical4j) + implementation(libs.slf4j.jdk) // ical4j uses slf4j, this module uses java.util.Logger + + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.mockk.android) + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/lib/consumer-rules.pro b/lib/consumer-rules.pro new file mode 100644 index 00000000..18a085fe --- /dev/null +++ b/lib/consumer-rules.pro @@ -0,0 +1,11 @@ + +# keep all iCalendar properties/parameters (referenced over ServiceLoader) +-keep class net.fortuna.ical4j.** { *; } + +# don't warn when these are missing +-dontwarn com.github.erosb.jsonsKema.** +-dontwarn groovy.** +-dontwarn java.beans.Transient +-dontwarn javax.cache.** +-dontwarn org.codehaus.groovy.** +-dontwarn org.jparsec.** diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidCalendarTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt similarity index 85% rename from src/androidTest/java/at/bitfire/ical4android/AndroidCalendarTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt index dbd9c4c4..d2b0256b 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidCalendarTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -16,13 +18,18 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.impl.TestEvent -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.closeCompat import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart -import org.junit.* +import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test class AndroidCalendarTest { @@ -77,7 +84,7 @@ class AndroidCalendarTest { assertNotNull(calendar) // delete calendar - assertEquals(1, calendar.delete()) + assertTrue(calendar.delete()) } @@ -117,11 +124,11 @@ class AndroidCalendarTest { } private fun countColors(account: Account): Int { - val uri = Colors.CONTENT_URI.asSyncAdapter(testAccount) + val uri = Colors.CONTENT_URI.asSyncAdapter(account) provider.query(uri, null, null, null, null)!!.use { cursor -> cursor.moveToNext() return cursor.count } } -} +} \ No newline at end of file diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt similarity index 75% rename from src/androidTest/java/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt index d4e2209d..4b27c749 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistryTest.kt @@ -1,13 +1,17 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.TimeZoneRegistry -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assume import org.junit.Before import org.junit.Test @@ -17,9 +21,9 @@ import java.time.zone.ZoneRulesException class AndroidCompatTimeZoneRegistryTest { lateinit var ical4jRegistry: TimeZoneRegistry - lateinit var registry: TimeZoneRegistry + lateinit var registry: AndroidCompatTimeZoneRegistry - val systemKnowsKyiv = + private val systemKnowsKyiv = try { ZoneId.of("Europe/Kyiv") true @@ -29,7 +33,7 @@ class AndroidCompatTimeZoneRegistryTest { @Before fun createRegistry() { - ical4jRegistry = DefaultTimeZoneRegistryFactory.getInstance().createRegistry() + ical4jRegistry = DefaultTimeZoneRegistryFactory().createRegistry() registry = AndroidCompatTimeZoneRegistry.Factory().createRegistry() } @@ -75,6 +79,13 @@ class AndroidCompatTimeZoneRegistryTest { ) } + @Test + fun getTimeZone_Copenhagen_NoBerlin() { + val tz = registry.getTimeZone("Europe/Copenhagen")!! + assertEquals("Europe/Copenhagen", tz.id) + assertFalse(tz.vTimeZone.toString().contains("Berlin")) + } + @Test fun getTimeZone_NotExisting() { assertNull(registry.getTimeZone("Test/NotExisting")) diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt similarity index 91% rename from src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt index cb094cac..db3f431b 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import android.Manifest @@ -10,26 +12,66 @@ import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils import android.net.Uri -import android.provider.CalendarContract.* +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.AUTHORITY +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.impl.TestEvent import at.bitfire.ical4android.util.AndroidTimeUtils import at.bitfire.ical4android.util.DateUtils -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter -import net.fortuna.ical4j.model.* +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.* +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.Language +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.* import net.fortuna.ical4j.util.TimeZones -import org.junit.* -import org.junit.Assert.* +import org.junit.After +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test import java.net.URI import java.time.Duration import java.time.Period -import java.util.TimeZone +import java.util.logging.Logger +import kotlin.collections.Map +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.emptyMap +import kotlin.collections.first +import kotlin.collections.firstOrNull +import kotlin.collections.iterator +import kotlin.collections.mapOf +import kotlin.collections.plusAssign class AndroidEventTest { @@ -58,6 +100,8 @@ class AndroidEventTest { } + private val logger = Logger.getLogger(javaClass.name) + private val testAccount = Account("ical4android@example.com", ACCOUNT_TYPE_LOCAL) private val tzVienna = DateUtils.ical4jTimeZone("Europe/Vienna")!! @@ -265,6 +309,21 @@ class AndroidEventTest { assertEquals("${tzShanghai.id};20200601T123000,20200701T183000,20200702T183000,20200801T123000,20200802T123000", values.getAsString(Events.RDATE)) } + @Test + fun testBuildEvent_NonAllDay_DtEnd_NoDuration_Recurring_InfiniteRruleAndRdate() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzShanghai) + dtEnd = DtEnd("20200601T123000", tzVienna) + rRules += RRule( + Recur("FREQ=DAILY;INTERVAL=2") + ) + rDates += RDate(DateList("20200701T123000,20200702T123000", Value.DATE_TIME, tzVienna)) + } + + assertNull(values.get(Events.RDATE)) + assertEquals("FREQ=DAILY;INTERVAL=2", values.get(Events.RRULE)) + } + @Test fun testBuildEvent_NonAllDay_DtEnd_Duration_NonRecurring() { val values = buildEvent(false) { @@ -518,7 +577,7 @@ class AndroidEventTest { buildEvent(true) { url = URI("https://example.com") }.let { result -> - assertEquals("https://example.com", firstExtendedProperty(result, AndroidEvent.MIMETYPE_URL)) + assertEquals("https://example.com", firstExtendedProperty(result, AndroidEvent.EXTNAME_URL)) } } @@ -533,7 +592,6 @@ class AndroidEventTest { @Test fun testBuildEvent_Color_WhenNotAvailable() { - AndroidCalendar.removeColors(provider, testAccount) buildEvent(true) { color = Css3Color.darkseagreen }.let { result -> @@ -698,11 +756,20 @@ class AndroidEventTest { fun testBuildEvent_Classification_None() { buildEvent(true) { }.let { result -> - assertEquals(Events.ACCESS_PUBLIC, result.getAsInteger(Events.ACCESS_LEVEL)) + assertEquals(Events.ACCESS_DEFAULT, result.getAsInteger(Events.ACCESS_LEVEL)) assertNull(firstUnknownProperty(result)) } } + @Test + fun testBuildEvent_UID2445() { + buildEvent(true) { + uid = "event1@example.com" + }.let { result -> + assertEquals("event1@example.com", result.getAsString(Events.UID_2445)) + } + } + private fun firstReminder(row: ContentValues): ContentValues? { val id = row.getAsInteger(Events._ID) @@ -1269,6 +1336,16 @@ class AndroidEventTest { } } + @Test + fun testBuildUnknownProperty_NoValue() { + buildEvent(true) { + unknownProperties += XProperty("ATTACH", ParameterList(), null) + }.let { result -> + // The property should not have been added, so the first unknown property should be null + assertNull(firstUnknownProperty(result)) + } + } + private fun firstException(values: ContentValues): ContentValues? { val id = values.getAsInteger(Events._ID) provider.query(Events.CONTENT_URI.asSyncAdapter(testAccount), null, @@ -1385,13 +1462,14 @@ class AndroidEventTest { } - private fun populateEvent( - automaticDates: Boolean, - destinationCalendar: TestCalendar = calendar, - asSyncAdapter: Boolean = false, - insertCallback: (id: Long) -> Unit = {}, - valuesBuilder: ContentValues.() -> Unit - ): Event { + private fun populateAndroidEvent( + automaticDates: Boolean, + destinationCalendar: TestCalendar = calendar, + asSyncAdapter: Boolean = false, + insertCallback: (id: Long) -> Unit = {}, + extendedProperties: Map = emptyMap(), + valuesBuilder: ContentValues.() -> Unit = {} + ): AndroidEvent { val values = ContentValues() values.put(Events.CALENDAR_ID, destinationCalendar.id) if (automaticDates) { @@ -1401,20 +1479,47 @@ class AndroidEventTest { values.put(Events.EVENT_END_TIMEZONE, "Europe/Berlin") } valuesBuilder(values) - Ical4Android.log.info("Inserting test event: $values") + logger.info("Inserting test event: $values") val uri = provider.insert( - if (asSyncAdapter) - Events.CONTENT_URI.asSyncAdapter(testAccount) - else - Events.CONTENT_URI, - values)!! + if (asSyncAdapter) + Events.CONTENT_URI.asSyncAdapter(testAccount) + else + Events.CONTENT_URI, + values)!! val id = ContentUris.parseId(uri) // insert additional rows etc. insertCallback(id) - val androidEvent = destinationCalendar.findById(id) - return androidEvent.event!! + // insert extended properties + for ((name, value) in extendedProperties) { + val extendedValues = contentValuesOf( + ExtendedProperties.EVENT_ID to id, + ExtendedProperties.NAME to name, + ExtendedProperties.VALUE to value + ) + provider.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), extendedValues) + } + + return destinationCalendar.findById(id) + } + + private fun populateEvent( + automaticDates: Boolean, + destinationCalendar: TestCalendar = calendar, + asSyncAdapter: Boolean = false, + insertCallback: (id: Long) -> Unit = {}, + extendedProperties: Map = emptyMap(), + valuesBuilder: ContentValues.() -> Unit = {} + ): Event { + return populateAndroidEvent( + automaticDates, + destinationCalendar, + asSyncAdapter, + insertCallback, + extendedProperties, + valuesBuilder + ).event!! } @Test @@ -1587,14 +1692,10 @@ class AndroidEventTest { } @Test - fun textPopulateEvent_Url() { - populateEvent(true, insertCallback = { id -> - val urlValues = ContentValues() - urlValues.put(ExtendedProperties.EVENT_ID, id) - urlValues.put(ExtendedProperties.NAME, AndroidEvent.MIMETYPE_URL) - urlValues.put(ExtendedProperties.VALUE, "https://example.com") - provider.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), urlValues) - }, valuesBuilder = {}).let { result -> + fun testPopulateEvent_Url() { + populateEvent(true, + extendedProperties = mapOf(AndroidEvent.EXTNAME_URL to "https://example.com") + ).let { result -> assertEquals(URI("https://example.com"), result.url) } } @@ -1609,7 +1710,7 @@ class AndroidEventTest { } @Test - fun testPopulateEvent_Color() { + fun testPopulateEvent_Color_FromIndex() { AndroidCalendar.insertColors(provider, testAccount) populateEvent(true) { put(Events.EVENT_COLOR_KEY, Css3Color.silver.name) @@ -1618,6 +1719,15 @@ class AndroidEventTest { } } + @Test + fun testPopulateEvent_Color_FromValue() { + populateEvent(true) { + put(Events.EVENT_COLOR, Css3Color.silver.argb) + }.let { result -> + assertEquals(Css3Color.silver, result.color) + } + } + @Test fun testPopulateEvent_Status_Confirmed() { populateEvent(true) { @@ -1647,10 +1757,7 @@ class AndroidEventTest { @Test fun testPopulateEvent_Status_None() { - populateEvent(true) { - }.let { result -> - assertNull(result.status) - } + assertNull(populateEvent(true).status) } @Test @@ -1682,10 +1789,7 @@ class AndroidEventTest { @Test fun testPopulateEvent_Organizer_NotGroupScheduled() { - populateEvent(true) { - }.let { result -> - assertNull(result.organizer) - } + assertNull(populateEvent(true).organizer) } @Test @@ -1699,15 +1803,15 @@ class AndroidEventTest { @Test fun testPopulateEvent_Organizer_GroupScheduled() { - populateEvent(true, valuesBuilder = { - put(Events.ORGANIZER, "organizer@example.com") - }, insertCallback = { id -> + populateEvent(true, insertCallback = { id -> provider.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), ContentValues().apply { put(Attendees.EVENT_ID, id) put(Attendees.ATTENDEE_EMAIL, "organizer@example.com") put(Attendees.ATTENDEE_TYPE, Attendees.RELATIONSHIP_ORGANIZER) }) - }).let { result -> + }) { + put(Events.ORGANIZER, "organizer@example.com") + }.let { result -> assertEquals("mailto:organizer@example.com", result.organizer?.value) } } @@ -1741,15 +1845,11 @@ class AndroidEventTest { @Test fun testPopulateEvent_Classification_Confidential_Retained() { - populateEvent(true, valuesBuilder = { + populateEvent(true, + extendedProperties = mapOf(UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz.CONFIDENTIAL)) + ) { put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }, insertCallback = { id -> - provider.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), ContentValues().apply { - put(ExtendedProperties.EVENT_ID, id) - put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) - put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(Clazz.CONFIDENTIAL)) - }) - }).let { result -> + }.let { result -> assertEquals(Clazz.CONFIDENTIAL, result.classification) } } @@ -1765,15 +1865,15 @@ class AndroidEventTest { @Test fun testPopulateEvent_Classification_Custom() { - populateEvent(true, valuesBuilder = { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }, insertCallback = { id -> - provider.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), ContentValues().apply { - put(ExtendedProperties.EVENT_ID, id) - put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) - put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(Clazz("TOP-SECRET"))) - }) - }).let { result -> + populateEvent( + true, + valuesBuilder = { + put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) + }, + extendedProperties = mapOf( + UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz("TOP-SECRET")) + ) + ).let { result -> assertEquals(Clazz("TOP-SECRET"), result.classification) } } @@ -1786,13 +1886,46 @@ class AndroidEventTest { } } + @Test + fun testPopulateEvent_Uid_iCalUid() { + populateEvent( + true, + extendedProperties = mapOf( + AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" + ) + ).let { result -> + assertEquals("event1@example.com", result.uid) + } + } + + @Test + fun testPopulateEvent_Uid_UID_2445() { + populateEvent(true) { + put(Events.UID_2445, "event1@example.com") + }.let { result -> + assertEquals("event1@example.com", result.uid) + } + } + + @Test + fun testPopulateEvent_Uid_UID_2445_and_iCalUid() { + populateEvent(true, + extendedProperties = mapOf( + AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" + )) { + put(Events.UID_2445, "event2@example.com") + }.let { result -> + assertEquals("event2@example.com", result.uid) + } + } + private fun populateReminder(destinationCalendar: TestCalendar = calendar, builder: ContentValues.() -> Unit): VAlarm? { - populateEvent(true, destinationCalendar = destinationCalendar, valuesBuilder = {}, insertCallback = { id -> + populateEvent(true, destinationCalendar = destinationCalendar, insertCallback = { id -> val reminderValues = ContentValues() reminderValues.put(Reminders.EVENT_ID, id) builder(reminderValues) - Ical4Android.log.info("Inserting test reminder: $reminderValues") + logger.info("Inserting test reminder: $reminderValues") provider.insert(Reminders.CONTENT_URI.asSyncAdapter(testAccount), reminderValues) }).let { result -> return result.alarms.firstOrNull() @@ -1865,11 +1998,11 @@ class AndroidEventTest { private fun populateAttendee(builder: ContentValues.() -> Unit): Attendee? { - populateEvent(true, valuesBuilder = {}, insertCallback = { id -> + populateEvent(true, insertCallback = { id -> val attendeeValues = ContentValues() attendeeValues.put(Attendees.EVENT_ID, id) builder(attendeeValues) - Ical4Android.log.info("Inserting test attendee: $attendeeValues") + logger.info("Inserting test attendee: $attendeeValues") provider.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), attendeeValues) }).let { result -> return result.attendees.firstOrNull() @@ -2104,13 +2237,12 @@ class AndroidEventTest { val params = ParameterList() params.add(Language("en")) val unknownProperty = XProperty("X-NAME", params, "Custom Value") - val result = populateEvent(true, valuesBuilder = {}, insertCallback = { id -> - val values = ContentValues() - values.put(ExtendedProperties.EVENT_ID, id) - values.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) - values.put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(unknownProperty)) - provider.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), values) - }).unknownProperties.first + val (result) = populateEvent( + true, + extendedProperties = mapOf( + UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(unknownProperty) + ) + ).unknownProperties assertEquals("X-NAME", result.name) assertEquals("en", result.getParameter(Parameter.LANGUAGE).value) assertEquals("Custom Value", result.value) @@ -2145,8 +2277,8 @@ class AndroidEventTest { }).let { event -> assertEquals("Recurring non-all-day event with exception", event.summary) assertEquals(DtStart("20200706T193000", tzVienna), event.dtStart) - assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first.value) - val exception = event.exceptions.first!! + assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first().value) + val exception = event.exceptions.first() assertEquals(RecurrenceId("20200708T013000", tzShanghai), exception.recurrenceId) assertEquals(DtStart("20200706T203000", tzShanghai), exception.dtStart) assertEquals("Event moved to one hour later", exception.summary) @@ -2173,8 +2305,8 @@ class AndroidEventTest { }).let { event -> assertEquals("Recurring all-day event with exception", event.summary) assertEquals(DtStart(Date("20200706")), event.dtStart) - assertEquals("FREQ=WEEKLY;COUNT=3", event.rRules.first.value) - val exception = event.exceptions.first!! + assertEquals("FREQ=WEEKLY;COUNT=3", event.rRules.first().value) + val exception = event.exceptions.first() assertEquals(RecurrenceId(Date("20200707")), exception.recurrenceId) assertEquals(DtStart("20200706T183000", tzShanghai), exception.dtStart) assertEquals("Today not an all-day event", exception.summary) @@ -2201,8 +2333,8 @@ class AndroidEventTest { }).let { event -> assertEquals("Recurring all-day event with cancelled exception", event.summary) assertEquals(DtStart("20200706T193000", tzVienna), event.dtStart) - assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first.value) - assertEquals(DateTime("20200708T013000", tzShanghai), event.exDates.first.dates.first()) + assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first().value) + assertEquals(DateTime("20200708T013000", tzShanghai), event.exDates.first().dates.first()) assertTrue(event.exceptions.isEmpty()) } } @@ -2244,6 +2376,30 @@ class AndroidEventTest { } } + @Test + fun testUpdateEvent_ResetColor() { + // add event with color + val event = Event().apply { + uid = "sample1@testAddEvent" + dtStart = DtStart(DateTime()) + color = Css3Color.silver + } + val uri = TestEvent(calendar, event).add() + val id = ContentUris.parseId(uri) + + // verify that it has color + val beforeUpdate = calendar.findById(id) + assertNotNull(beforeUpdate.event?.color) + + // update: reset color + event.color = null + beforeUpdate.update(event) + + // verify that it doesn't have color anymore + val afterUpdate = calendar.findById(id) + assertNull(afterUpdate.event!!.color) + } + @Test fun testUpdateEvent_UpdateStatusFromNull() { val event = Event() diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidTimeZonesTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidTimeZonesTest.kt similarity index 70% rename from src/androidTest/java/at/bitfire/ical4android/AndroidTimeZonesTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidTimeZonesTest.kt index d6199adc..a94dff2b 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidTimeZonesTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidTimeZonesTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -10,7 +12,7 @@ import org.junit.Assert.assertNotNull import org.junit.Test import java.time.ZoneId import java.time.format.TextStyle -import java.util.* +import java.util.Locale class AndroidTimeZonesTest { diff --git a/src/androidTest/java/at/bitfire/ical4android/AospTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt similarity index 91% rename from src/androidTest/java/at/bitfire/ical4android/AospTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt index c048f25d..92ddc2c4 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AospTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -12,7 +14,7 @@ import android.net.Uri import android.provider.CalendarContract import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.util.MiscUtils.closeCompat import org.junit.After import org.junit.Assert.assertNotNull import org.junit.Before diff --git a/src/androidTest/java/at/bitfire/ical4android/AttendeeMappingsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt similarity index 98% rename from src/androidTest/java/at/bitfire/ical4android/AttendeeMappingsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt index 69e08426..2854c4dc 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AttendeeMappingsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android diff --git a/src/androidTest/java/at/bitfire/ical4android/BatchOperationTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/BatchOperationTest.kt similarity index 89% rename from src/androidTest/java/at/bitfire/ical4android/BatchOperationTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/BatchOperationTest.kt index 4ef31480..9b887144 100644 --- a/src/androidTest/java/at/bitfire/ical4android/BatchOperationTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/BatchOperationTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -15,15 +17,20 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.impl.TestEvent -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.util.MiscUtils.closeCompat import net.fortuna.ical4j.model.property.Attendee import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart -import org.junit.* +import org.junit.After +import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test import java.net.URI -import java.util.* +import java.util.Arrays class BatchOperationTest { diff --git a/src/androidTest/java/at/bitfire/ical4android/Css3ColorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Css3ColorTest.kt similarity index 80% rename from src/androidTest/java/at/bitfire/ical4android/Css3ColorTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/Css3ColorTest.kt index 04080b40..5e9a61f2 100644 --- a/src/androidTest/java/at/bitfire/ical4android/Css3ColorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Css3ColorTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android diff --git a/src/androidTest/java/at/bitfire/ical4android/AbstractTasksTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt similarity index 74% rename from src/androidTest/java/at/bitfire/ical4android/AbstractTasksTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt index e77b7f90..3c626614 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AbstractTasksTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -12,10 +14,11 @@ import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith import org.junit.runners.Parameterized +import java.util.logging.Logger @RunWith(Parameterized::class) -abstract class AbstractTasksTest( +abstract class DmfsStyleProvidersTaskTest( val providerName: TaskProvider.ProviderName ) { @@ -38,7 +41,7 @@ abstract class AbstractTasksTest( Assume.assumeNotNull(providerOrNull) // will halt here if providerOrNull is null provider = providerOrNull!! - Ical4Android.log.fine("Using task provider: $provider") + Logger.getLogger(javaClass.name).fine("Using task provider: $provider") } @After diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidTaskListTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt similarity index 81% rename from src/androidTest/java/at/bitfire/ical4android/AndroidTaskListTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt index f7e9398d..a62bb1ca 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidTaskListTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -15,11 +17,13 @@ import org.dmfs.tasks.contract.TaskContract import org.dmfs.tasks.contract.TaskContract.Properties import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.Tasks -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test -class AndroidTaskListTest(providerName: TaskProvider.ProviderName): - AbstractTasksTest(providerName) { +class DmfsTaskListTest(providerName: TaskProvider.ProviderName): + DmfsStyleProvidersTaskTest(providerName) { private val testAccount = Account("AndroidTaskListTest", TaskContract.LOCAL_ACCOUNT_TYPE) @@ -32,10 +36,10 @@ class AndroidTaskListTest(providerName: TaskProvider.ProviderName): info.put(TaskContract.TaskLists.SYNC_ENABLED, 1) info.put(TaskContract.TaskLists.VISIBLE, 1) - val uri = AndroidTaskList.create(testAccount, provider, info) + val uri = DmfsTaskList.create(testAccount, provider.client, providerName, info) assertNotNull(uri) - return AndroidTaskList.findByID(testAccount, provider, TestTaskList.Factory, ContentUris.parseId(uri)) + return DmfsTaskList.findByID(testAccount, provider.client, providerName, TestTaskList.Factory, ContentUris.parseId(uri)) } @@ -54,7 +58,7 @@ class AndroidTaskListTest(providerName: TaskProvider.ProviderName): assertEquals(testAccount.name, taskList.tasksSyncUri().getQueryParameter(TaskContract.ACCOUNT_NAME)) } finally { // delete task list - assertEquals(1, taskList.delete()) + assertTrue(taskList.delete()) } } @@ -78,7 +82,7 @@ class AndroidTaskListTest(providerName: TaskProvider.ProviderName): val parentId = ContentUris.parseId(parentContentUri) // OpenTasks should provide the correct relation - taskList.provider.client.query(taskList.tasksPropertiesSyncUri(), null, + taskList.provider.query(taskList.tasksPropertiesSyncUri(), null, "${Properties.TASK_ID}=?", arrayOf(childId.toString()), null, null)!!.use { cursor -> assertEquals(1, cursor.count) @@ -97,7 +101,7 @@ class AndroidTaskListTest(providerName: TaskProvider.ProviderName): taskList.touchRelations() // now parent_id should bet set - taskList.provider.client.query(childContentUri, arrayOf(Tasks.PARENT_ID), + taskList.provider.query(childContentUri, arrayOf(Tasks.PARENT_ID), null, null, null)!!.use { cursor -> assertTrue(cursor.moveToNext()) assertEquals(parentId, cursor.getLong(0)) diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidTaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt similarity index 83% rename from src/androidTest/java/at/bitfire/ical4android/AndroidTaskTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt index 61565cb0..bfa77edc 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidTaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -9,35 +11,57 @@ import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils import android.net.Uri -import androidx.test.filters.MediumTest import at.bitfire.ical4android.impl.TestTask import at.bitfire.ical4android.impl.TestTaskList import at.bitfire.ical4android.util.DateUtils import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.parameter.* +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.TzId -import net.fortuna.ical4j.model.property.* -import org.dmfs.tasks.contract.TaskContract -import org.dmfs.tasks.contract.TaskContract.* +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.XProperty +import org.dmfs.tasks.contract.TaskContract.LOCAL_ACCOUNT_TYPE +import org.dmfs.tasks.contract.TaskContract.Properties +import org.dmfs.tasks.contract.TaskContract.Property import org.dmfs.tasks.contract.TaskContract.Property.Category import org.dmfs.tasks.contract.TaskContract.Property.Relation +import org.dmfs.tasks.contract.TaskContract.PropertyColumns +import org.dmfs.tasks.contract.TaskContract.Tasks import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.time.ZoneId -class AndroidTaskTest( - providerName: TaskProvider.ProviderName -): AbstractTasksTest(providerName) { +class DmfsTaskTest( + providerName: TaskProvider.ProviderName +): DmfsStyleProvidersTaskTest(providerName) { private val tzVienna = DateUtils.ical4jTimeZone("Europe/Vienna")!! private val tzChicago = DateUtils.ical4jTimeZone("America/Chicago")!! private val tzDefault = DateUtils.ical4jTimeZone(ZoneId.systemDefault().id)!! - private val testAccount = Account("AndroidTaskTest", TaskContract.LOCAL_ACCOUNT_TYPE) + private val testAccount = Account("AndroidTaskTest", LOCAL_ACCOUNT_TYPE) private lateinit var taskListUri: Uri private var taskList: TestTaskList? = null @@ -76,7 +100,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Sequence() { - buildTask() { + buildTask { sequence = 12345 }.let { result -> assertEquals(12345, result.getAsInteger(Tasks.SYNC_VERSION)) @@ -85,7 +109,7 @@ class AndroidTaskTest( @Test fun testBuildTask_CreatedAt() { - buildTask() { + buildTask { createdAt = 1593771404 // Fri Jul 03 10:16:44 2020 UTC }.let { result -> assertEquals(1593771404, result.getAsLong(Tasks.CREATED)) @@ -94,7 +118,7 @@ class AndroidTaskTest( @Test fun testBuildTask_LastModified() { - buildTask() { + buildTask { lastModified = 1593771404 }.let { result -> assertEquals(1593771404, result.getAsLong(Tasks.LAST_MODIFIED)) @@ -103,7 +127,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Summary() { - buildTask() { + buildTask { summary = "Sample Summary" }.let { result -> assertEquals("Sample Summary", result.get(Tasks.TITLE)) @@ -112,7 +136,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Location() { - buildTask() { + buildTask { location = "Sample Location" }.let { result -> assertEquals("Sample Location", result.get(Tasks.LOCATION)) @@ -121,7 +145,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Geo() { - buildTask() { + buildTask { geoPosition = Geo(47.913563.toBigDecimal(), 16.159601.toBigDecimal()) }.let { result -> assertEquals("16.159601,47.913563", result.get(Tasks.GEO)) @@ -130,7 +154,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Description() { - buildTask() { + buildTask { description = "Sample Description" }.let { result -> assertEquals("Sample Description", result.get(Tasks.DESCRIPTION)) @@ -139,7 +163,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Color() { - buildTask() { + buildTask { color = 0x11223344 }.let { result -> assertEquals(0x11223344, result.getAsInteger(Tasks.TASK_COLOR)) @@ -148,7 +172,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Url() { - buildTask() { + buildTask { url = "https://www.example.com" }.let { result -> assertEquals("https://www.example.com", result.getAsString(Tasks.URL)) @@ -157,7 +181,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Organizer_MailTo() { - buildTask() { + buildTask { organizer = Organizer("mailto:organizer@example.com") }.let { result -> assertEquals("organizer@example.com", result.getAsString(Tasks.ORGANIZER)) @@ -166,7 +190,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Organizer_EmailParameter() { - buildTask() { + buildTask { organizer = Organizer("uri:unknown").apply { parameters.add(Email("organizer@example.com")) } @@ -177,7 +201,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Organizer_NotEmail() { - buildTask() { + buildTask { organizer = Organizer("uri:unknown") }.let { result -> assertNull(result.get(Tasks.ORGANIZER)) @@ -186,7 +210,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Priority() { - buildTask() { + buildTask { priority = 2 }.let { result -> assertEquals(2, result.getAsInteger(Tasks.PRIORITY)) @@ -195,7 +219,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Classification_Public() { - buildTask() { + buildTask { classification = Clazz.PUBLIC }.let { result -> assertEquals(Tasks.CLASSIFICATION_PUBLIC, result.getAsInteger(Tasks.CLASSIFICATION)) @@ -204,7 +228,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Classification_Private() { - buildTask() { + buildTask { classification = Clazz.PRIVATE }.let { result -> assertEquals(Tasks.CLASSIFICATION_PRIVATE, result.getAsInteger(Tasks.CLASSIFICATION)) @@ -213,7 +237,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Classification_Confidential() { - buildTask() { + buildTask { classification = Clazz.CONFIDENTIAL }.let { result -> assertEquals(Tasks.CLASSIFICATION_CONFIDENTIAL, result.getAsInteger(Tasks.CLASSIFICATION)) @@ -222,7 +246,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Classification_Custom() { - buildTask() { + buildTask { classification = Clazz("x-custom") }.let { result -> assertEquals(Tasks.CLASSIFICATION_PRIVATE, result.getAsInteger(Tasks.CLASSIFICATION)) @@ -231,7 +255,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Classification_None() { - buildTask() { + buildTask { }.let { result -> assertEquals(Tasks.CLASSIFICATION_DEFAULT /* null */, result.getAsInteger(Tasks.CLASSIFICATION)) } @@ -239,7 +263,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Status_NeedsAction() { - buildTask() { + buildTask { status = Status.VTODO_NEEDS_ACTION }.let { result -> assertEquals(Tasks.STATUS_NEEDS_ACTION, result.getAsInteger(Tasks.STATUS)) @@ -248,7 +272,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Status_Completed() { - buildTask() { + buildTask { status = Status.VTODO_COMPLETED }.let { result -> assertEquals(Tasks.STATUS_COMPLETED, result.getAsInteger(Tasks.STATUS)) @@ -257,7 +281,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Status_InProcess() { - buildTask() { + buildTask { status = Status.VTODO_IN_PROCESS }.let { result -> assertEquals(Tasks.STATUS_IN_PROCESS, result.getAsInteger(Tasks.STATUS)) @@ -266,7 +290,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Status_Cancelled() { - buildTask() { + buildTask { status = Status.VTODO_CANCELLED }.let { result -> assertEquals(Tasks.STATUS_CANCELLED, result.getAsInteger(Tasks.STATUS)) @@ -275,7 +299,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart() { - buildTask() { + buildTask { dtStart = DtStart("20200703T155722", tzVienna) }.let { result -> assertEquals(1593784642000L, result.getAsLong(Tasks.DTSTART)) @@ -286,7 +310,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart_AllDay() { - buildTask() { + buildTask { dtStart = DtStart(Date("20200703")) }.let { result -> assertEquals(1593734400000L, result.getAsLong(Tasks.DTSTART)) @@ -297,7 +321,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Due() { - buildTask() { + buildTask { due = Due(DateTime("20200703T155722", tzVienna)) }.let { result -> assertEquals(1593784642000L, result.getAsLong(Tasks.DUE)) @@ -308,7 +332,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Due_AllDay() { - buildTask() { + buildTask { due = Due(Date("20200703")) }.let { result -> assertEquals(1593734400000L, result.getAsLong(Tasks.DUE)) @@ -319,7 +343,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart_NonAllDay_Due_AllDay() { - buildTask() { + buildTask { dtStart = DtStart(DateTime("20200101T010203")) due = Due(Date("20200201")) }.let { result -> @@ -330,7 +354,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart_AllDay_Due_NonAllDay() { - buildTask() { + buildTask { dtStart = DtStart(Date("20200101")) due = Due(DateTime("20200201T010203")) }.let { result -> @@ -341,7 +365,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart_AllDay_Due_AllDay() { - buildTask() { + buildTask { dtStart = DtStart(Date("20200101")) due = Due(Date("20200201")) }.let { result -> @@ -351,7 +375,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart_FloatingTime() { - buildTask() { + buildTask { dtStart = DtStart("20200703T010203") }.let { result -> assertEquals(DateTime("20200703T010203").time, result.getAsLong(Tasks.DTSTART)) @@ -362,7 +386,7 @@ class AndroidTaskTest( @Test fun testBuildTask_DtStart_Utc() { - buildTask() { + buildTask { dtStart = DtStart(DateTime(1593730923000), true) }.let { result -> assertEquals(1593730923000L, result.getAsLong(Tasks.DTSTART)) @@ -373,7 +397,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Due_FloatingTime() { - buildTask() { + buildTask { due = Due("20200703T010203") }.let { result -> assertEquals(DateTime("20200703T010203").time, result.getAsLong(Tasks.DUE)) @@ -384,7 +408,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Due_Utc() { - buildTask() { + buildTask { due = Due(DateTime(1593730923000).apply { isUtc = true }) }.let { result -> assertEquals(1593730923000L, result.getAsLong(Tasks.DUE)) @@ -395,7 +419,7 @@ class AndroidTaskTest( @Test fun testBuildTask_Duration() { - buildTask() { + buildTask { dtStart = DtStart(DateTime()) duration = Duration(null, "P1D") }.let { result -> @@ -406,7 +430,7 @@ class AndroidTaskTest( @Test fun testBuildTask_CompletedAt() { val now = DateTime() - buildTask() { + buildTask { completedAt = Completed(now) }.let { result -> // Note: iCalendar does not allow COMPLETED to be all-day [RFC 5545 3.8.2.1] @@ -417,7 +441,7 @@ class AndroidTaskTest( @Test fun testBuildTask_PercentComplete() { - buildTask() { + buildTask { percentComplete = 50 }.let { result -> assertEquals(50, result.getAsInteger(Tasks.PERCENT_COMPLETE)) @@ -427,7 +451,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RRule() { // Note: OpenTasks only supports one RRULE per VTODO (iCalendar: multiple RRULEs are allowed, but SHOULD not be used) - buildTask() { + buildTask { rRule = RRule("FREQ=DAILY;COUNT=10") }.let { result -> assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(Tasks.RRULE)) @@ -436,7 +460,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RDate() { - buildTask() { + buildTask { dtStart = DtStart(DateTime("20200101T010203", tzVienna)) rDates += RDate(DateList("20200102T020304", Value.DATE_TIME, tzVienna)) rDates += RDate(DateList("20200102T020304", Value.DATE_TIME, tzChicago)) @@ -450,7 +474,7 @@ class AndroidTaskTest( @Test fun testBuildTask_ExDate() { - buildTask() { + buildTask { dtStart = DtStart(DateTime("20200101T010203", tzVienna)) rRule = RRule("FREQ=DAILY;COUNT=10") exDates += ExDate(DateList("20200102T020304", Value.DATE_TIME, tzVienna)) @@ -467,7 +491,7 @@ class AndroidTaskTest( fun testBuildTask_Categories() { var hasCat1 = false var hasCat2 = false - buildTask() { + buildTask { categories.addAll(arrayOf("Cat_1", "Cat 2")) }.let { result -> val id = result.getAsLong(Tasks._ID) @@ -485,6 +509,39 @@ class AndroidTaskTest( assertTrue(hasCat2) } + @Test + fun testBuildTask_Comment() { + var hasComment = false + buildTask { + comment = "Comment value" + }.let { result -> + val id = result.getAsLong(Tasks._ID) + val uri = taskList!!.tasksPropertiesSyncUri() + provider.client.query(uri, arrayOf(Property.Comment.COMMENT), "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", + arrayOf(Property.Comment.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> + if (cursor.moveToNext()) + hasComment = cursor.getString(0) == "Comment value" + } + } + assertTrue(hasComment) + } + + @Test + fun testBuildTask_Comment_empty() { + var hasComment: Boolean + buildTask { + comment = null + }.let { result -> + val id = result.getAsLong(Tasks._ID) + val uri = taskList!!.tasksPropertiesSyncUri() + provider.client.query(uri, arrayOf(Property.Comment.COMMENT), "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", + arrayOf(Property.Comment.CONTENT_ITEM_TYPE, id.toString()), null)!!.use { cursor -> + hasComment = cursor.count > 0 + } + } + assertFalse(hasComment) + } + private fun firstProperty(taskId: Long, mimeType: String): ContentValues? { val uri = taskList!!.tasksPropertiesSyncUri() provider.client.query(uri, null, "${Properties.MIMETYPE}=? AND ${PropertyColumns.TASK_ID}=?", @@ -500,7 +557,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RelatedTo_Parent() { - buildTask() { + buildTask { relatedTo.add(RelatedTo("Parent-Task").apply { parameters.add(RelType.PARENT) }) @@ -515,7 +572,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RelatedTo_Child() { - buildTask() { + buildTask { relatedTo.add(RelatedTo("Child-Task").apply { parameters.add(RelType.CHILD) }) @@ -530,7 +587,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RelatedTo_Sibling() { - buildTask() { + buildTask { relatedTo.add(RelatedTo("Sibling-Task").apply { parameters.add(RelType.SIBLING) }) @@ -545,7 +602,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RelatedTo_Custom() { - buildTask() { + buildTask { relatedTo.add(RelatedTo("Sibling-Task").apply { parameters.add(RelType("custom-relationship")) }) @@ -560,7 +617,7 @@ class AndroidTaskTest( @Test fun testBuildTask_RelatedTo_Default() { - buildTask() { + buildTask { relatedTo.add(RelatedTo("Parent-Task")) }.let { result -> val taskId = result.getAsLong(Tasks._ID) @@ -578,17 +635,16 @@ class AndroidTaskTest( parameters.add(TzId(tzVienna.id)) parameters.add(XParameter("X-TEST-PARAMETER", "12345")) } - buildTask() { + buildTask { unknownProperties.add(xProperty) }.let { result -> val taskId = result.getAsLong(Tasks._ID) val unknownProperty = firstProperty(taskId, UnknownProperty.CONTENT_ITEM_TYPE)!! - assertEquals(xProperty, UnknownProperty.fromJsonString(unknownProperty.getAsString(AndroidTask.UNKNOWN_PROPERTY_DATA))) + assertEquals(xProperty, UnknownProperty.fromJsonString(unknownProperty.getAsString(DmfsTask.UNKNOWN_PROPERTY_DATA))) } } - @MediumTest @Test fun testAddTask() { // build and write event to calendar provider @@ -604,6 +660,7 @@ class AndroidTaskTest( // extended properties task.categories.addAll(arrayOf("Cat1", "Cat2")) + task.comment = "A comment" val sibling = RelatedTo("most-fields2@example.com") sibling.parameters.add(RelType.SIBLING) @@ -629,6 +686,7 @@ class AndroidTaskTest( assertEquals(task.dtStart, task2.dtStart) assertEquals(task.categories, task2.categories) + assertEquals(task.comment, task2.comment) assertEquals(task.relatedTo, task2.relatedTo) assertEquals(task.unknownProperties, task2.unknownProperties) } finally { @@ -636,7 +694,6 @@ class AndroidTaskTest( } } - @MediumTest @Test(expected = CalendarStorageException::class) fun testAddTaskWithInvalidDue() { val task = Task() @@ -648,7 +705,21 @@ class AndroidTaskTest( TestTask(taskList!!, task).add() } - @MediumTest + @Test + fun testAddTaskWithManyAlarms() { + val task = Task() + task.uid = "TaskWithManyAlarms" + task.summary = "Task with many alarms" + task.dtStart = DtStart(Date("20150102")) + + for (i in 1..1050) + task.alarms += VAlarm(java.time.Duration.ofMinutes(i.toLong())) + + val uri = TestTask(taskList!!, task).add() + val task2 = taskList!!.findById(ContentUris.parseId(uri)) + assertEquals(1050, task2.task?.alarms?.size) + } + @Test fun testUpdateTask() { // add test event without reminder @@ -682,7 +753,6 @@ class AndroidTaskTest( } } - @MediumTest @Test fun testBuildAllDayTask() { // add all-day event to calendar provider diff --git a/src/androidTest/java/at/bitfire/ical4android/EventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/EventTest.kt similarity index 90% rename from src/androidTest/java/at/bitfire/ical4android/EventTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/EventTest.kt index e016b07b..27a0ca88 100644 --- a/src/androidTest/java/at/bitfire/ical4android/EventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/EventTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -8,11 +10,18 @@ import at.bitfire.ical4android.util.DateUtils import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.property.* -import org.junit.Assert.* +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayOutputStream import java.io.FileNotFoundException @@ -85,15 +94,16 @@ class EventTest { e = findEvent(events, "multiple-1@ical4android.EventTest") assertEquals("Event 1", e.summary) assertEquals(1, e.exceptions.size) - assertEquals("Event 1 Exception", e.exceptions.first.summary) + assertEquals("Event 1 Exception", e.exceptions.first().summary) e = findEvent(events, "multiple-2@ical4android.EventTest") assertEquals("Event 2", e.summary) assertEquals(2, e.exceptions.size) - assertTrue("Event 2 Updated Exception 1" == e.exceptions.first.summary || "Event 2 Updated Exception 1" == e.exceptions[1].summary) - assertTrue("Event 2 Exception 2" == e.exceptions.first.summary || "Event 2 Exception 2" == e.exceptions[1].summary) + assertTrue("Event 2 Updated Exception 1" == e.exceptions.first().summary || "Event 2 Updated Exception 1" == e.exceptions[1].summary) + assertTrue("Event 2 Exception 2" == e.exceptions.first().summary || "Event 2 Exception 2" == e.exceptions[1].summary) } + @Test fun testParse() { val event = parseCalendar("utf8.ics").first() @@ -102,9 +112,9 @@ class EventTest { assertEquals("Test Description", event.description) assertEquals("中华人民共和国", event.location) assertEquals(Css3Color.aliceblue, event.color) - assertEquals("cyrus@example.com", event.attendees.first.parameters.getParameter("EMAIL").value) + assertEquals("cyrus@example.com", event.attendees.first().parameters.getParameter("EMAIL").value) - val unknown = event.unknownProperties.first + val (unknown) = event.unknownProperties assertEquals("X-UNKNOWN-PROP", unknown.name) assertEquals("xxx", unknown.getParameter("param1").value) assertEquals("Unknown Value", unknown.value) @@ -154,7 +164,7 @@ class EventTest { assertTrue(DateUtils.isDate(event.dtStart)) assertEquals(1, event.exceptions.size) - val exception = event.exceptions.first + val (exception) = event.exceptions assertEquals("20150503", exception.recurrenceId!!.value) assertEquals("Another summary for the third day", exception.summary) } @@ -164,7 +174,7 @@ class EventTest { val event = parseCalendar("recurring-only-exception.ics").first() assertEquals(1, event.exceptions.size) - val exception = event.exceptions.first + val (exception) = event.exceptions assertEquals("20150503T010203Z", exception.recurrenceId!!.value) assertEquals("This is an exception", exception.summary) @@ -236,7 +246,7 @@ class EventTest { fun testWrite() { val e = Event() e.uid = "SAMPLEUID" - e.dtStart = DtStart("20190101T100000", TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Europe/Berlin")) + e.dtStart = DtStart("20190101T100000", DateUtils.ical4jTimeZone("Europe/Berlin")) e.alarms += VAlarm(Duration.ofHours(-1)) val os = ByteArrayOutputStream() @@ -331,4 +341,4 @@ class EventTest { return Event.eventsFromReader(InputStreamReader(stream, charset)) } -} +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalPreprocessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalPreprocessorTest.kt new file mode 100644 index 00000000..419f0382 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalPreprocessorTest.kt @@ -0,0 +1,54 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import androidx.test.filters.SdkSuppress +import at.bitfire.ical4android.validation.FixInvalidDayOffsetPreprocessor +import at.bitfire.ical4android.validation.FixInvalidUtcOffsetPreprocessor +import at.bitfire.ical4android.validation.ICalPreprocessor +import io.mockk.mockkObject +import io.mockk.verify +import net.fortuna.ical4j.data.CalendarBuilder +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.component.VEvent +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.InputStreamReader +import java.io.StringReader + +class ICalPreprocessorTest { + + @Test + @SdkSuppress(minSdkVersion = 28) + fun testPreprocessStream_appliesStreamProcessors() { + // Can only run on API Level 28 or newer because mockkObject doesn't support Android < P + mockkObject(FixInvalidDayOffsetPreprocessor, FixInvalidUtcOffsetPreprocessor) { + ICalPreprocessor.preprocessStream(StringReader("")) + + // verify that the required stream processors have been called + verify { + FixInvalidDayOffsetPreprocessor.preprocess(any()) + FixInvalidUtcOffsetPreprocessor.preprocess(any()) + } + } + } + + + @Test + fun testPreprocessCalendar_MsTimeZones() { + javaClass.classLoader!!.getResourceAsStream("events/outlook1.ics").use { stream -> + val reader = InputStreamReader(stream, Charsets.UTF_8) + val calendar = CalendarBuilder().build(reader) + val vEvent = calendar.getComponent(Component.VEVENT) as VEvent + + assertEquals("W. Europe Standard Time", vEvent.startDate.timeZone.id) + ICalPreprocessor.preprocessCalendar(calendar) + assertEquals("Europe/Vienna", vEvent.startDate.timeZone.id) + } + } + +} \ No newline at end of file diff --git a/src/androidTest/java/at/bitfire/ical4android/ICalendarTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalendarTest.kt similarity index 90% rename from src/androidTest/java/at/bitfire/ical4android/ICalendarTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalendarTest.kt index a62fb725..6530fe61 100644 --- a/src/androidTest/java/at/bitfire/ical4android/ICalendarTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/ICalendarTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -19,6 +21,7 @@ import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.util.TimeZones import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import java.io.StringReader @@ -28,19 +31,19 @@ import java.time.Period class ICalendarTest { // UTC timezone - val tzUTC = DateUtils.ical4jTimeZone(TimeZones.UTC_ID)!!.vTimeZone + private val tzUTC = DateUtils.ical4jTimeZone(TimeZones.UTC_ID)!!.vTimeZone // Austria (Europa/Vienna) uses DST regularly - val vtzVienna = readTimeZone("Vienna.ics") + private val vtzVienna = readTimeZone("Vienna.ics") // Pakistan (Asia/Karachi) used DST only in 2002, 2008 and 2009; no known future occurrences - val vtzKarachi = readTimeZone("Karachi.ics") + private val vtzKarachi = readTimeZone("Karachi.ics") // Somalia (Africa/Mogadishu) has never used DST - val vtzMogadishu = readTimeZone("Mogadishu.ics") + private val vtzMogadishu = readTimeZone("Mogadishu.ics") // current time stamp - val currentTime = java.util.Date().time + private val currentTime = java.util.Date().time private fun readTimeZone(fileName: String): VTimeZone { @@ -70,6 +73,26 @@ class ICalendarTest { assertEquals("#123456", calendar.getProperty(ICalendar.CALENDAR_COLOR).value) } + @Test + fun testFromReader_invalidProperty() { + // The GEO property is invalid and should be ignored. + // The calendar is however parsed without exception. + assertNotNull(ICalendar.fromReader( + StringReader( + "BEGIN:VCALENDAR\n" + + "PRODID:something\n" + + "VERSION:2.0\n" + + "BEGIN:VEVENT\n" + + "UID:xxx@example.com\n" + + "SUMMARY:Example Event with invalid GEO property\n" + + "GEO:37.7957246371765\n" + + "END:VEVENT\n" + + "END:VCALENDAR" + ) + )) + } + + @Test fun testMinifyVTimezone_UTC() { // Keep the only observance for UTC. @@ -90,8 +113,8 @@ class ICalendarTest { ICalendar.minifyVTimeZone(vtzVienna, Date("20200101")).let { minified -> assertEquals(2, minified.observances.size) // now earliest observance for DAYLIGHT/STANDARD is 1981/1996 - assertEquals(DateTime("19810329T020000"), minified.observances[0].startDate.date) - assertEquals(DateTime("19961027T030000"), minified.observances[1].startDate.date) + assertEquals(DateTime("19961027T030000"), minified.observances[0].startDate.date) + assertEquals(DateTime("19810329T020000"), minified.observances[1].startDate.date) } } @@ -111,8 +134,8 @@ class ICalendarTest { // Keep future observances. ICalendar.minifyVTimeZone(vtzVienna, Date("19751001")).let { minified -> assertEquals(4, minified.observances.size) - assertEquals(DateTime("19160430T230000"), minified.observances[2].startDate.date) - assertEquals(DateTime("19161001T010000"), minified.observances[3].startDate.date) + assertEquals(DateTime("19161001T010000"), minified.observances[2].startDate.date) + assertEquals(DateTime("19160430T230000"), minified.observances[3].startDate.date) } ICalendar.minifyVTimeZone(vtzKarachi, Date("19611001")).let { minified -> assertEquals(4, minified.observances.size) diff --git a/src/androidTest/java/at/bitfire/ical4android/Ical4jSettingsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Ical4jSettingsTest.kt similarity index 67% rename from src/androidTest/java/at/bitfire/ical4android/Ical4jSettingsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/Ical4jSettingsTest.kt index 7386ef47..ec86a357 100644 --- a/src/androidTest/java/at/bitfire/ical4android/Ical4jSettingsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Ical4jSettingsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android diff --git a/src/androidTest/java/at/bitfire/ical4android/Ical4jTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Ical4jTest.kt similarity index 85% rename from src/androidTest/java/at/bitfire/ical4android/Ical4jTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/Ical4jTest.kt index 52d988dd..e3aa41dc 100644 --- a/src/androidTest/java/at/bitfire/ical4android/Ical4jTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/Ical4jTest.kt @@ -1,11 +1,18 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import net.fortuna.ical4j.data.CalendarBuilder -import net.fortuna.ical4j.model.* +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.TemporalAmountAdapter +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VTimeZone import net.fortuna.ical4j.model.parameter.Email import org.junit.Assert.assertEquals @@ -33,7 +40,7 @@ class Ical4jTest { "END:VCALENDAR" ) ).first() - assertEquals("attendee1@example.virtual", e.attendees.first.getParameter(Parameter.EMAIL).value) + assertEquals("attendee1@example.virtual", e.attendees.first().getParameter(Parameter.EMAIL).value) } @Test @@ -68,9 +75,10 @@ class Ical4jTest { assertEquals(1616720400000, dt2.time) } - @Test(expected = AssertionError::class) - fun testTzDublin_external() { + @Test + fun testTzDublin_negativeDst() { // https://github.com/ical4j/ical4j/issues/493 + // fixed by enabling net.fortuna.ical4j.timezone.offset.negative_dst_supported in ical4j.properties val vtzFromGoogle = "BEGIN:VCALENDAR\n" + "CALSCALE:GREGORIAN\n" + "VERSION:2.0\n" + diff --git a/src/androidTest/java/at/bitfire/ical4android/JtxCollectionTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt similarity index 64% rename from src/androidTest/java/at/bitfire/ical4android/JtxCollectionTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt index 3e9f8868..06512c9a 100644 --- a/src/androidTest/java/at/bitfire/ical4android/JtxCollectionTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -10,11 +12,19 @@ import android.content.ContentValues import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestJtxCollection -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.util.MiscUtils.closeCompat import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.asSyncAdapter -import junit.framework.TestCase.* -import org.junit.* +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import org.junit.After +import org.junit.AfterClass +import org.junit.Assume +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test class JtxCollectionTest { @@ -105,6 +115,31 @@ class JtxCollectionTest { assertEquals(1, icalobjects.size) } + @Test + fun queryRecur_test() { + val collectionUri = JtxCollection.create(testAccount, client, cv) + assertNotNull(collectionUri) + + val collections = JtxCollection.find(testAccount, client, context, TestJtxCollection.Factory, null, null) + val item = collections[0].queryRecur("abc1234", "xyz5678") + assertNull(item) + + val cv = ContentValues().apply { + put(JtxContract.JtxICalObject.UID, "abc1234") + put(JtxContract.JtxICalObject.RECURID, "xyz5678") + put(JtxContract.JtxICalObject.RECURID_TIMEZONE, "Europe/Vienna") + put(JtxContract.JtxICalObject.SUMMARY, "summary") + put(JtxContract.JtxICalObject.COMPONENT, JtxContract.JtxICalObject.Component.VJOURNAL.name) + put(JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID, collections[0].id) + } + client.insert(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(testAccount), cv) + val contentValues = collections[0].queryRecur("abc1234", "xyz5678") + + assertEquals("abc1234", contentValues?.getAsString(JtxContract.JtxICalObject.UID)) + assertEquals("xyz5678", contentValues?.getAsString(JtxContract.JtxICalObject.RECURID)) + assertEquals("Europe/Vienna", contentValues?.getAsString(JtxContract.JtxICalObject.RECURID_TIMEZONE)) + } + @Test fun getICSForCollection_test() { val collectionUri = JtxCollection.create(testAccount, client, cv) @@ -135,4 +170,26 @@ class JtxCollectionTest { assertTrue(ics.contains(Regex("BEGIN:VJOURNAL(\\n*|\\r*|\\t*|.*)*END:VJOURNAL"))) assertTrue(ics.contains(Regex("BEGIN:VTODO(\\n*|\\r*|\\t*|.*)*END:VTODO"))) } + + + @Test + fun updateLastSync_test() { + val collectionUri = JtxCollection.create(testAccount, client, cv) + assertNotNull(collectionUri) + val collections = JtxCollection.find(testAccount, client, context, TestJtxCollection.Factory, null, null) + + collections.forEach { collection -> + client.query(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(testAccount), arrayOf(JtxContract.JtxCollection.LAST_SYNC), null, emptyArray(), null).use { + assertNotNull(it) + assertTrue(it!!.moveToFirst()) + assertTrue(it.isNull(0)) + } + collection.updateLastSync() + client.query(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(testAccount), arrayOf(JtxContract.JtxCollection.LAST_SYNC), null, emptyArray(), null).use { + assertNotNull(it) + assertTrue(it!!.moveToFirst()) + assertTrue(!it.isNull(0)) + } + } + } } diff --git a/src/androidTest/java/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt similarity index 96% rename from src/androidTest/java/at/bitfire/ical4android/JtxICalObjectTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index 8cf65ba2..95dcd30f 100644 --- a/src/androidTest/java/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -1,18 +1,23 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import android.accounts.Account import android.content.ContentProviderClient +import android.content.ContentResolver import android.content.ContentValues +import android.content.Context import android.database.DatabaseUtils import android.os.ParcelFileDescriptor +import androidx.core.content.pm.PackageInfoCompat import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestJtxCollection -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.util.MiscUtils.closeCompat import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject import at.techbee.jtx.JtxContract.JtxICalObject.Component @@ -28,14 +33,14 @@ class JtxICalObjectTest { companion object { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val contentResolver = context.contentResolver + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + private val contentResolver: ContentResolver = context.contentResolver private lateinit var client: ContentProviderClient @JvmField @ClassRule - val permissionRule = GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX) + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX) @BeforeClass @JvmStatic @@ -55,8 +60,8 @@ class JtxICalObjectTest { } private val testAccount = Account("TEST", JtxContract.JtxCollection.TEST_ACCOUNT_TYPE) - var collection: JtxCollection? = null - var sample: at.bitfire.ical4android.JtxICalObject? = null + private var collection: JtxCollection? = null + private var sample: at.bitfire.ical4android.JtxICalObject? = null private val url = "https://jtx.techbee.at" private val displayname = "jtxTest" @@ -85,6 +90,7 @@ class JtxICalObjectTest { this.dtend = System.currentTimeMillis() this.dtendTimezone = "Europe/Paris" this.status = JtxICalObject.StatusJournal.FINAL.name + this.xstatus = "my status" this.classification = JtxICalObject.Classification.PUBLIC.name this.url = "https://jtx.techbee.at" this.contact = "jtx@techbee.at" @@ -92,6 +98,7 @@ class JtxICalObjectTest { this.geoLong = 16.3738 this.location = "Vienna" this.locationAltrep = "Wien" + this.geofenceRadius = 10 this.percent = 99 this.priority = 1 this.due = System.currentTimeMillis() @@ -134,6 +141,12 @@ class JtxICalObjectTest { @Test fun check_DTEND() = insertRetrieveAssertLong(JtxICalObject.DTEND, sample?.dtend, Component.VJOURNAL.name) @Test fun check_DTEND_TIMEZONE() = insertRetrieveAssertString(JtxICalObject.DTEND_TIMEZONE, sample?.dtendTimezone, Component.VJOURNAL.name) @Test fun check_STATUS() = insertRetrieveAssertString(JtxICalObject.STATUS, sample?.status, Component.VJOURNAL.name) + @Test fun check_XSTATUS() { + val jtxVersionCode = PackageInfoCompat.getLongVersionCode(context.packageManager.getPackageInfo("at.techbee.jtx", 0)) + Assume.assumeTrue(jtxVersionCode > 204020003) + insertRetrieveAssertString(JtxICalObject.EXTENDED_STATUS, sample?.xstatus, Component.VJOURNAL.name) + } + @Test fun check_CLASSIFICATION() = insertRetrieveAssertString(JtxICalObject.CLASSIFICATION, sample?.classification, Component.VJOURNAL.name) @Test fun check_URL() = insertRetrieveAssertString(JtxICalObject.URL, sample?.url, Component.VJOURNAL.name) @Test fun check_CONTACT() = insertRetrieveAssertString(JtxICalObject.CONTACT, sample?.contact, Component.VJOURNAL.name) @@ -141,6 +154,12 @@ class JtxICalObjectTest { @Test fun check_GEO_LONG() = insertRetrieveAssertDouble(JtxICalObject.GEO_LONG, sample?.geoLong, Component.VJOURNAL.name) @Test fun check_LOCATION() = insertRetrieveAssertString(JtxICalObject.LOCATION, sample?.location, Component.VJOURNAL.name) @Test fun check_LOCATION_ALTREP() = insertRetrieveAssertString(JtxICalObject.LOCATION_ALTREP, sample?.locationAltrep, Component.VJOURNAL.name) + @Test fun check_GEOFENCE_RADIUS() { + val jtxVersionCode = PackageInfoCompat.getLongVersionCode(context.packageManager.getPackageInfo("at.techbee.jtx", 0)) + Assume.assumeTrue(jtxVersionCode > 204020003) + insertRetrieveAssertInt(JtxICalObject.GEOFENCE_RADIUS, sample?.geofenceRadius, Component.VJOURNAL.name) + } + @Test fun check_PERCENT() = insertRetrieveAssertInt(JtxICalObject.PERCENT, sample?.percent, Component.VJOURNAL.name) @Test fun check_PRIORITY() = insertRetrieveAssertInt(JtxICalObject.PRIORITY, sample?.priority, Component.VJOURNAL.name) @Test fun check_DUE() = insertRetrieveAssertLong(JtxICalObject.DUE, sample?.due, Component.VJOURNAL.name) diff --git a/src/androidTest/java/at/bitfire/ical4android/LocaleNonWesternDigitsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/LocaleNonWesternDigitsTest.kt similarity index 75% rename from src/androidTest/java/at/bitfire/ical4android/LocaleNonWesternDigitsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/LocaleNonWesternDigitsTest.kt index 81f91c16..1e651d29 100644 --- a/src/androidTest/java/at/bitfire/ical4android/LocaleNonWesternDigitsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/LocaleNonWesternDigitsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -8,10 +10,9 @@ import net.fortuna.ical4j.model.property.TzOffsetFrom import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.BeforeClass -import org.junit.ComparisonFailure import org.junit.Test import java.time.ZoneOffset -import java.util.* +import java.util.Locale class LocaleNonWesternDigitsTest { @@ -45,7 +46,7 @@ class LocaleNonWesternDigitsTest { assertEquals("2020", String.format(Locale.ROOT, "%d", 2020)) } - @Test(expected = ComparisonFailure::class) // should not fail in future + @Test() fun testLocale_ical4j() { val offset = TzOffsetFrom(ZoneOffset.ofHours(1)) val iCal = offset.toString() diff --git a/src/androidTest/java/at/bitfire/ical4android/TaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt similarity index 87% rename from src/androidTest/java/at/bitfire/ical4android/TaskTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt index 35b266d5..3e373f69 100644 --- a/src/androidTest/java/at/bitfire/ical4android/TaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt @@ -1,16 +1,35 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import at.bitfire.ical4android.util.DateUtils -import net.fortuna.ical4j.model.* +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.* -import org.junit.Assert.* +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.Status +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.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -157,11 +176,11 @@ class TaskTest { assertArrayEquals(arrayOf("Test","Sample"), t.categories.toArray()) - val sibling = t.relatedTo.first + val (sibling) = t.relatedTo assertEquals("most-fields2@example.com", sibling.value) assertEquals(RelType.SIBLING, (sibling.getParameter(Parameter.RELTYPE) as RelType)) - val unknown = t.unknownProperties.first + val (unknown) = t.unknownProperties assertEquals("X-UNKNOWN-PROP", unknown.name) assertEquals("xxx", unknown.getParameter("param1").value) assertEquals("Unknown Value", unknown.value) @@ -181,7 +200,7 @@ class TaskTest { fun testWrite() { val t = Task() t.uid = "SAMPLEUID" - t.dtStart = DtStart("20190101T100000", TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Europe/Berlin")) + t.dtStart = DtStart("20190101T100000", DateUtils.ical4jTimeZone("Europe/Berlin")) val alarm = VAlarm(java.time.Duration.ofHours(-1) /*Dur(0, -1, 0, 0)*/) alarm.properties += Action.AUDIO diff --git a/src/androidTest/java/at/bitfire/ical4android/UnknownPropertyTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/UnknownPropertyTest.kt similarity index 86% rename from src/androidTest/java/at/bitfire/ical4android/UnknownPropertyTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/UnknownPropertyTest.kt index b4a8be5d..f0f4a5f7 100644 --- a/src/androidTest/java/at/bitfire/ical4android/UnknownPropertyTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/UnknownPropertyTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android diff --git a/src/androidTest/java/at/bitfire/ical4android/impl/TestCalendar.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt similarity index 84% rename from src/androidTest/java/at/bitfire/ical4android/impl/TestCalendar.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt index 19d37cbc..d900dbc3 100644 --- a/src/androidTest/java/at/bitfire/ical4android/impl/TestCalendar.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.impl diff --git a/src/androidTest/java/at/bitfire/ical4android/impl/TestEvent.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt similarity index 66% rename from src/androidTest/java/at/bitfire/ical4android/impl/TestEvent.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt index 11270a11..ac9d8c2d 100644 --- a/src/androidTest/java/at/bitfire/ical4android/impl/TestEvent.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt @@ -1,13 +1,19 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.impl import android.content.ContentValues import android.provider.CalendarContract.Events -import at.bitfire.ical4android.* -import java.util.* +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.AndroidEventFactory +import at.bitfire.ical4android.BatchOperation +import at.bitfire.ical4android.Event +import java.util.UUID class TestEvent: AndroidEvent { diff --git a/src/androidTest/java/at/bitfire/ical4android/impl/TestJtxCollection.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestJtxCollection.kt similarity index 81% rename from src/androidTest/java/at/bitfire/ical4android/impl/TestJtxCollection.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestJtxCollection.kt index 2bef3064..5adc42e3 100644 --- a/src/androidTest/java/at/bitfire/ical4android/impl/TestJtxCollection.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestJtxCollection.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.impl @@ -9,9 +11,9 @@ import android.content.ContentProviderClient import at.bitfire.ical4android.JtxCollection import at.bitfire.ical4android.JtxCollectionFactory import at.bitfire.ical4android.JtxICalObject -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues +import at.bitfire.ical4android.util.MiscUtils.toValues import at.techbee.jtx.JtxContract -import java.util.* +import java.util.LinkedList class TestJtxCollection( account: Account, diff --git a/src/androidTest/java/at/bitfire/ical4android/impl/TestJtxIcalObject.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestJtxIcalObject.kt similarity index 65% rename from src/androidTest/java/at/bitfire/ical4android/impl/TestJtxIcalObject.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestJtxIcalObject.kt index a1397e97..8862eccb 100644 --- a/src/androidTest/java/at/bitfire/ical4android/impl/TestJtxIcalObject.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestJtxIcalObject.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.impl diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTask.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTask.kt new file mode 100644 index 00000000..86ed0c2e --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTask.kt @@ -0,0 +1,29 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android.impl + +import android.content.ContentValues + +import at.bitfire.ical4android.DmfsTask +import at.bitfire.ical4android.DmfsTaskFactory +import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.ical4android.Task + +class TestTask: DmfsTask { + + constructor(taskList: DmfsTaskList, values: ContentValues) + : super(taskList, values) + + constructor(taskList: TestTaskList, task: Task) + : super(taskList, task) + + object Factory: DmfsTaskFactory { + override fun fromProvider(taskList: DmfsTaskList, values: ContentValues) = + TestTask(taskList, values) + } + +} diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt new file mode 100644 index 00000000..a1c5797f --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestTaskList.kt @@ -0,0 +1,49 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android.impl + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.ical4android.DmfsTaskListFactory +import at.bitfire.ical4android.TaskProvider +import org.dmfs.tasks.contract.TaskContract + +class TestTaskList( + account: Account, + provider: ContentProviderClient, + providerName: TaskProvider.ProviderName, + id: Long +): DmfsTaskList(account, provider, providerName, TestTask.Factory, id) { + + companion object { + + fun create( + account: Account, + provider: TaskProvider, + ): TestTaskList { + val values = ContentValues(4) + values.put(TaskContract.TaskListColumns.LIST_NAME, "Test Task List") + values.put(TaskContract.TaskListColumns.LIST_COLOR, 0xffff0000) + values.put(TaskContract.TaskListColumns.SYNC_ENABLED, 1) + values.put(TaskContract.TaskListColumns.VISIBLE, 1) + val uri = DmfsTaskList.create(account, provider.client, provider.name, values) + + return TestTaskList(account, provider.client, provider.name, ContentUris.parseId(uri)) + } + + } + + + object Factory: DmfsTaskListFactory { + override fun newInstance(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, id: Long) = + TestTaskList(account, provider, providerName, id) + } + +} diff --git a/src/androidTest/java/at/bitfire/ical4android/util/AndroidTimeUtilsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/AndroidTimeUtilsTest.kt similarity index 87% rename from src/androidTest/java/at/bitfire/ical4android/util/AndroidTimeUtilsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/util/AndroidTimeUtilsTest.kt index 18a37f97..04ec8236 100644 --- a/src/androidTest/java/at/bitfire/ical4android/util/AndroidTimeUtilsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/AndroidTimeUtilsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.util @@ -14,7 +16,10 @@ import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.util.TimeZones -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.io.StringReader import java.time.Duration @@ -319,10 +324,32 @@ class AndroidTimeUtilsTest { @Test fun testRecurrenceSetsToAndroidString_Date() { - // DATEs (without time) have to be converted to T000000Z for Android + // DATEs (without time) have to be converted to THHmmssZ for Android val list = ArrayList(1) - list.add(RDate(DateList("20150101,20150702", Value.DATE))) - assertEquals("20150101T000000Z,20150702T000000Z", AndroidTimeUtils.recurrenceSetsToAndroidString(list, true)) + list.add(RDate(DateList("20150101,20150702", Value.DATE, tzDefault))) + val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, Date("20150101")) + // We ignore the timezone + assertEquals("20150101T000000Z,20150702T000000Z", androidTimeString.substringAfter(';')) + } + + @Test + fun testRecurrenceSetsToAndroidString_Date_AlthoughDtStartIsDateTime() { + // DATEs (without time) have to be converted to THHmmssZ for Android + val list = ArrayList(1) + list.add(RDate(DateList("20150101,20150702", Value.DATE, tzDefault))) + val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150101T043210", tzBerlin)) + // We ignore the timezone + assertEquals("20150101T033210Z,20150702T023210Z", androidTimeString.substringAfter(';')) + } + + @Test + fun testRecurrenceSetsToAndroidString_Date_AlthoughDtStartIsDateTime_MonthWithLessDays() { + // DATEs (without time) have to be converted to THHmmssZ for Android + val list = ArrayList(1) + list.add(ExDate(DateList("20240531", Value.DATE, tzDefault))) + val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20240401T114500", tzBerlin)) + // We ignore the timezone + assertEquals("20240531T094500Z", androidTimeString.substringAfter(';')) } @Test @@ -331,15 +358,17 @@ class AndroidTimeUtilsTest { val list = listOf( RDate(PeriodList("19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H")) ) - assertEquals("", AndroidTimeUtils.recurrenceSetsToAndroidString(list, false)) + assertEquals("", AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("19960403T020000Z"))) } @Test - fun testRecurrenceSetsToAndroidString_TimeAlthoughAllDay() { + fun testRecurrenceSetsToAndroidString_Time_AlthoughDtStartIsAllDay() { // DATE-TIME (floating time or UTC) recurrences for all-day events have to converted to T000000Z for Android val list = ArrayList(1) - list.add(RDate(DateList("20150101T000000,20150702T000000Z", Value.DATE_TIME))) - assertEquals("20150101T000000Z,20150702T000000Z", AndroidTimeUtils.recurrenceSetsToAndroidString(list, true)) + list.add(RDate(DateList("20150101T000000,20150702T000000Z", Value.DATE_TIME, tzDefault))) + val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, Date("20150101")) + // We ignore the timezone + assertEquals("20150101T000000Z,20150702T000000Z", androidTimeString.substringAfter(';')) } @Test @@ -348,7 +377,7 @@ class AndroidTimeUtilsTest { val list = ArrayList(2) list.add(RDate(DateList("20150103T113030", Value.DATE_TIME, tzToronto))) list.add(RDate(DateList("20150704T113040", Value.DATE_TIME, tzToronto))) - assertEquals("America/Toronto;20150103T113030,20150704T113040", AndroidTimeUtils.recurrenceSetsToAndroidString(list, false)) + assertEquals("America/Toronto;20150103T113030,20150704T113040", AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150103T113030", tzToronto))) } @Test @@ -359,7 +388,7 @@ class AndroidTimeUtilsTest { val list = ArrayList(2) list.add(RDate(DateList("20150103T113030", Value.DATE_TIME, tzToronto))) list.add(RDate(DateList("20150704T113040", Value.DATE_TIME, tzBerlin))) - assertEquals("America/Toronto;20150103T113030,20150704T053040", AndroidTimeUtils.recurrenceSetsToAndroidString(list, false)) + assertEquals("America/Toronto;20150103T113030,20150704T053040", AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150103T113030", tzToronto))) } @Test @@ -370,14 +399,14 @@ class AndroidTimeUtilsTest { val list = ArrayList(2) list.add(RDate(DateList("20150103T113030Z", Value.DATE_TIME))) list.add(RDate(DateList("20150704T113040", Value.DATE_TIME, tzBerlin))) - assertEquals("20150103T113030Z,20150704T093040Z", AndroidTimeUtils.recurrenceSetsToAndroidString(list, false)) + assertEquals("20150103T113030Z,20150704T093040Z", AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150103T113030Z"))) } @Test fun testRecurrenceSetsToAndroidString_UtcTime() { val list = ArrayList(1) list.add(RDate(DateList("20150101T103010Z,20150102T103020Z", Value.DATE_TIME))) - assertEquals("20150101T103010Z,20150102T103020Z", AndroidTimeUtils.recurrenceSetsToAndroidString(list, false)) + assertEquals("20150101T103010Z,20150102T103020Z", AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150101T103010ZZ"))) } diff --git a/src/androidTest/java/at/bitfire/ical4android/util/DateUtilsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/DateUtilsTest.kt similarity index 67% rename from src/androidTest/java/at/bitfire/ical4android/util/DateUtilsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/util/DateUtilsTest.kt index fe1f468c..35a8e718 100644 --- a/src/androidTest/java/at/bitfire/ical4android/util/DateUtilsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/DateUtilsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.util @@ -8,10 +10,14 @@ import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.time.ZoneId -import java.util.* +import java.util.TimeZone class DateUtilsTest { @@ -59,4 +65,23 @@ class DateUtilsTest { assertFalse(DateUtils.isDateTime(null)) } + + @Test + fun testParseVTimeZone() { + val vtz = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:DAVx5 + BEGIN:VTIMEZONE + TZID:Asia/Shanghai + END:VTIMEZONE + END:VCALENDAR""".trimIndent() + assertEquals("Asia/Shanghai", DateUtils.parseVTimeZone(vtz)?.timeZoneId?.value) + } + + @Test + fun testParseVTimeZone_Invalid() { + assertNull(DateUtils.parseVTimeZone("Invalid")) + } + } diff --git a/src/androidTest/java/at/bitfire/ical4android/util/MiscUtilsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/MiscUtilsTest.kt similarity index 55% rename from src/androidTest/java/at/bitfire/ical4android/util/MiscUtilsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/util/MiscUtilsTest.kt index dee8dd91..9a052ca5 100644 --- a/src/androidTest/java/at/bitfire/ical4android/util/MiscUtilsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/MiscUtilsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.util @@ -9,17 +11,15 @@ import android.content.ContentValues import android.database.MatrixCursor import android.net.Uri import androidx.test.filters.SmallTest -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter -import org.junit.Assert +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.removeBlankStrings +import at.bitfire.ical4android.util.MiscUtils.toValues import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class MiscUtilsTest { - private val tzVienna = DateUtils.ical4jTimeZone("Europe/Vienna") - - @Test @SmallTest fun testCursorToValues() { @@ -34,15 +34,21 @@ class MiscUtilsTest { @Test @SmallTest - fun testRemoveEmptyStrings() { - val values = ContentValues(2) + fun testRemoveEmptyAndBlankStrings() { + val values = ContentValues() values.put("key1", "value") values.put("key2", 1L) values.put("key3", "") - MiscUtils.removeEmptyStrings(values) - Assert.assertEquals("value", values.getAsString("key1")) - Assert.assertEquals(1L, values.getAsLong("key2").toLong()) - Assert.assertNull(values.get("key3")) + values.put("key4", "\n") + values.put("key5", " \n ") + values.put("key6", " ") + values.removeBlankStrings() + assertEquals("value", values.getAsString("key1")) + assertEquals(1L, values.getAsLong("key2")) + assertNull(values.get("key3")) + assertNull(values.get("key4")) + assertNull(values.get("key5")) + assertNull(values.get("key6")) } @@ -56,4 +62,4 @@ class MiscUtilsTest { ) } -} +} \ No newline at end of file diff --git a/src/androidTest/java/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt similarity index 93% rename from src/androidTest/java/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt index e5c6fc08..3e05f5f0 100644 --- a/src/androidTest/java/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.util @@ -20,7 +22,15 @@ import net.fortuna.ical4j.util.TimeZones import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import java.time.* +import java.time.DayOfWeek +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.Period +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime class TimeApiExtensionsTest { diff --git a/src/androidTest/resources/events/all-day-0sec.ics b/lib/src/androidTest/resources/events/all-day-0sec.ics similarity index 100% rename from src/androidTest/resources/events/all-day-0sec.ics rename to lib/src/androidTest/resources/events/all-day-0sec.ics diff --git a/src/androidTest/resources/events/all-day-10days.ics b/lib/src/androidTest/resources/events/all-day-10days.ics similarity index 100% rename from src/androidTest/resources/events/all-day-10days.ics rename to lib/src/androidTest/resources/events/all-day-10days.ics diff --git a/src/androidTest/resources/events/all-day-1day.ics b/lib/src/androidTest/resources/events/all-day-1day.ics similarity index 100% rename from src/androidTest/resources/events/all-day-1day.ics rename to lib/src/androidTest/resources/events/all-day-1day.ics diff --git a/src/androidTest/resources/events/dst-only-vtimezone.ics b/lib/src/androidTest/resources/events/dst-only-vtimezone.ics similarity index 100% rename from src/androidTest/resources/events/dst-only-vtimezone.ics rename to lib/src/androidTest/resources/events/dst-only-vtimezone.ics diff --git a/src/androidTest/resources/events/event-on-that-day.ics b/lib/src/androidTest/resources/events/event-on-that-day.ics similarity index 100% rename from src/androidTest/resources/events/event-on-that-day.ics rename to lib/src/androidTest/resources/events/event-on-that-day.ics diff --git a/src/androidTest/resources/events/latin1.ics b/lib/src/androidTest/resources/events/latin1.ics similarity index 100% rename from src/androidTest/resources/events/latin1.ics rename to lib/src/androidTest/resources/events/latin1.ics diff --git a/src/androidTest/resources/events/multiple.ics b/lib/src/androidTest/resources/events/multiple.ics similarity index 100% rename from src/androidTest/resources/events/multiple.ics rename to lib/src/androidTest/resources/events/multiple.ics diff --git a/src/androidTest/resources/events/one-event-with-exception-one-without.ics b/lib/src/androidTest/resources/events/one-event-with-exception-one-without.ics similarity index 100% rename from src/androidTest/resources/events/one-event-with-exception-one-without.ics rename to lib/src/androidTest/resources/events/one-event-with-exception-one-without.ics diff --git a/src/androidTest/resources/events/one-event-with-multiple-exceptions-one-without.ics b/lib/src/androidTest/resources/events/one-event-with-multiple-exceptions-one-without.ics similarity index 100% rename from src/androidTest/resources/events/one-event-with-multiple-exceptions-one-without.ics rename to lib/src/androidTest/resources/events/one-event-with-multiple-exceptions-one-without.ics diff --git a/src/androidTest/resources/events/outlook1.ics b/lib/src/androidTest/resources/events/outlook1.ics similarity index 100% rename from src/androidTest/resources/events/outlook1.ics rename to lib/src/androidTest/resources/events/outlook1.ics diff --git a/src/androidTest/resources/events/recurring-only-exception.ics b/lib/src/androidTest/resources/events/recurring-only-exception.ics similarity index 100% rename from src/androidTest/resources/events/recurring-only-exception.ics rename to lib/src/androidTest/resources/events/recurring-only-exception.ics diff --git a/src/androidTest/resources/events/recurring-with-exception1.ics b/lib/src/androidTest/resources/events/recurring-with-exception1.ics similarity index 100% rename from src/androidTest/resources/events/recurring-with-exception1.ics rename to lib/src/androidTest/resources/events/recurring-with-exception1.ics diff --git a/src/androidTest/resources/events/two-events-without-exceptions.ics b/lib/src/androidTest/resources/events/two-events-without-exceptions.ics similarity index 100% rename from src/androidTest/resources/events/two-events-without-exceptions.ics rename to lib/src/androidTest/resources/events/two-events-without-exceptions.ics diff --git a/src/androidTest/resources/events/two-line-description-without-crlf.ics b/lib/src/androidTest/resources/events/two-line-description-without-crlf.ics similarity index 100% rename from src/androidTest/resources/events/two-line-description-without-crlf.ics rename to lib/src/androidTest/resources/events/two-line-description-without-crlf.ics diff --git a/src/androidTest/resources/events/utf8.ics b/lib/src/androidTest/resources/events/utf8.ics similarity index 100% rename from src/androidTest/resources/events/utf8.ics rename to lib/src/androidTest/resources/events/utf8.ics diff --git a/src/androidTest/resources/events/vienna-evolution.ics b/lib/src/androidTest/resources/events/vienna-evolution.ics similarity index 100% rename from src/androidTest/resources/events/vienna-evolution.ics rename to lib/src/androidTest/resources/events/vienna-evolution.ics diff --git a/src/androidTest/resources/jtx/vjournal/all-day.ics b/lib/src/androidTest/resources/jtx/vjournal/all-day.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/all-day.ics rename to lib/src/androidTest/resources/jtx/vjournal/all-day.ics diff --git a/src/androidTest/resources/jtx/vjournal/default-example-note.ics b/lib/src/androidTest/resources/jtx/vjournal/default-example-note.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/default-example-note.ics rename to lib/src/androidTest/resources/jtx/vjournal/default-example-note.ics diff --git a/src/androidTest/resources/jtx/vjournal/default-example.ics b/lib/src/androidTest/resources/jtx/vjournal/default-example.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/default-example.ics rename to lib/src/androidTest/resources/jtx/vjournal/default-example.ics diff --git a/src/androidTest/resources/jtx/vjournal/dst-only-vtimezone.ics b/lib/src/androidTest/resources/jtx/vjournal/dst-only-vtimezone.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/dst-only-vtimezone.ics rename to lib/src/androidTest/resources/jtx/vjournal/dst-only-vtimezone.ics diff --git a/src/androidTest/resources/jtx/vjournal/journal-on-that-day.ics b/lib/src/androidTest/resources/jtx/vjournal/journal-on-that-day.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/journal-on-that-day.ics rename to lib/src/androidTest/resources/jtx/vjournal/journal-on-that-day.ics diff --git a/src/androidTest/resources/jtx/vjournal/latin1.ics b/lib/src/androidTest/resources/jtx/vjournal/latin1.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/latin1.ics rename to lib/src/androidTest/resources/jtx/vjournal/latin1.ics diff --git a/src/androidTest/resources/jtx/vjournal/outlook-theoretical.ics b/lib/src/androidTest/resources/jtx/vjournal/outlook-theoretical.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/outlook-theoretical.ics rename to lib/src/androidTest/resources/jtx/vjournal/outlook-theoretical.ics diff --git a/src/androidTest/resources/jtx/vjournal/outlook-theoretical2.ics b/lib/src/androidTest/resources/jtx/vjournal/outlook-theoretical2.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/outlook-theoretical2.ics rename to lib/src/androidTest/resources/jtx/vjournal/outlook-theoretical2.ics diff --git a/src/androidTest/resources/jtx/vjournal/recurring.ics b/lib/src/androidTest/resources/jtx/vjournal/recurring.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/recurring.ics rename to lib/src/androidTest/resources/jtx/vjournal/recurring.ics diff --git a/src/androidTest/resources/jtx/vjournal/two-events-without-exceptions.ics b/lib/src/androidTest/resources/jtx/vjournal/two-events-without-exceptions.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/two-events-without-exceptions.ics rename to lib/src/androidTest/resources/jtx/vjournal/two-events-without-exceptions.ics diff --git a/src/androidTest/resources/jtx/vjournal/two-line-description-without-crlf.ics b/lib/src/androidTest/resources/jtx/vjournal/two-line-description-without-crlf.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/two-line-description-without-crlf.ics rename to lib/src/androidTest/resources/jtx/vjournal/two-line-description-without-crlf.ics diff --git a/src/androidTest/resources/jtx/vjournal/utf8.ics b/lib/src/androidTest/resources/jtx/vjournal/utf8.ics similarity index 100% rename from src/androidTest/resources/jtx/vjournal/utf8.ics rename to lib/src/androidTest/resources/jtx/vjournal/utf8.ics diff --git a/src/androidTest/resources/jtx/vtodo/empty-priority.ics b/lib/src/androidTest/resources/jtx/vtodo/empty-priority.ics similarity index 100% rename from src/androidTest/resources/jtx/vtodo/empty-priority.ics rename to lib/src/androidTest/resources/jtx/vtodo/empty-priority.ics diff --git a/src/androidTest/resources/jtx/vtodo/latin1.ics b/lib/src/androidTest/resources/jtx/vtodo/latin1.ics similarity index 100% rename from src/androidTest/resources/jtx/vtodo/latin1.ics rename to lib/src/androidTest/resources/jtx/vtodo/latin1.ics diff --git a/src/androidTest/resources/jtx/vtodo/most-fields1.ics b/lib/src/androidTest/resources/jtx/vtodo/most-fields1.ics similarity index 100% rename from src/androidTest/resources/jtx/vtodo/most-fields1.ics rename to lib/src/androidTest/resources/jtx/vtodo/most-fields1.ics diff --git a/src/androidTest/resources/jtx/vtodo/most-fields2.ics b/lib/src/androidTest/resources/jtx/vtodo/most-fields2.ics similarity index 100% rename from src/androidTest/resources/jtx/vtodo/most-fields2.ics rename to lib/src/androidTest/resources/jtx/vtodo/most-fields2.ics diff --git a/src/androidTest/resources/jtx/vtodo/rfc5545-sample1.ics b/lib/src/androidTest/resources/jtx/vtodo/rfc5545-sample1.ics similarity index 100% rename from src/androidTest/resources/jtx/vtodo/rfc5545-sample1.ics rename to lib/src/androidTest/resources/jtx/vtodo/rfc5545-sample1.ics diff --git a/src/androidTest/resources/jtx/vtodo/utf8.ics b/lib/src/androidTest/resources/jtx/vtodo/utf8.ics similarity index 100% rename from src/androidTest/resources/jtx/vtodo/utf8.ics rename to lib/src/androidTest/resources/jtx/vtodo/utf8.ics diff --git a/src/androidTest/resources/tasks/empty-priority.ics b/lib/src/androidTest/resources/tasks/empty-priority.ics similarity index 100% rename from src/androidTest/resources/tasks/empty-priority.ics rename to lib/src/androidTest/resources/tasks/empty-priority.ics diff --git a/src/androidTest/resources/tasks/latin1.ics b/lib/src/androidTest/resources/tasks/latin1.ics similarity index 100% rename from src/androidTest/resources/tasks/latin1.ics rename to lib/src/androidTest/resources/tasks/latin1.ics diff --git a/src/androidTest/resources/tasks/most-fields1.ics b/lib/src/androidTest/resources/tasks/most-fields1.ics similarity index 100% rename from src/androidTest/resources/tasks/most-fields1.ics rename to lib/src/androidTest/resources/tasks/most-fields1.ics diff --git a/src/androidTest/resources/tasks/most-fields2.ics b/lib/src/androidTest/resources/tasks/most-fields2.ics similarity index 100% rename from src/androidTest/resources/tasks/most-fields2.ics rename to lib/src/androidTest/resources/tasks/most-fields2.ics diff --git a/src/androidTest/resources/tasks/rfc5545-sample1.ics b/lib/src/androidTest/resources/tasks/rfc5545-sample1.ics similarity index 100% rename from src/androidTest/resources/tasks/rfc5545-sample1.ics rename to lib/src/androidTest/resources/tasks/rfc5545-sample1.ics diff --git a/src/androidTest/resources/tasks/utf8.ics b/lib/src/androidTest/resources/tasks/utf8.ics similarity index 100% rename from src/androidTest/resources/tasks/utf8.ics rename to lib/src/androidTest/resources/tasks/utf8.ics diff --git a/src/androidTest/resources/tz/Karachi.ics b/lib/src/androidTest/resources/tz/Karachi.ics similarity index 100% rename from src/androidTest/resources/tz/Karachi.ics rename to lib/src/androidTest/resources/tz/Karachi.ics diff --git a/src/androidTest/resources/tz/Mogadishu.ics b/lib/src/androidTest/resources/tz/Mogadishu.ics similarity index 100% rename from src/androidTest/resources/tz/Mogadishu.ics rename to lib/src/androidTest/resources/tz/Mogadishu.ics diff --git a/src/androidTest/resources/tz/Vienna.ics b/lib/src/androidTest/resources/tz/Vienna.ics similarity index 100% rename from src/androidTest/resources/tz/Vienna.ics rename to lib/src/androidTest/resources/tz/Vienna.ics diff --git a/src/main/AndroidManifest.xml b/lib/src/main/AndroidManifest.xml similarity index 64% rename from src/main/AndroidManifest.xml rename to lib/src/main/AndroidManifest.xml index f4ef8d93..385f58ba 100644 --- a/src/main/AndroidManifest.xml +++ b/lib/src/main/AndroidManifest.xml @@ -1,15 +1,3 @@ - - diff --git a/src/main/java/at/bitfire/ical4android/AndroidCalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt similarity index 77% rename from src/main/java/at/bitfire/ical4android/AndroidCalendar.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt index 127adf03..6efe3e82 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidCalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -9,12 +11,19 @@ import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues import android.net.Uri -import android.provider.CalendarContract.* -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.CalendarEntity +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Colors +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.Reminders +import androidx.annotation.CallSuper +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.toValues import java.io.FileNotFoundException -import java.util.* +import java.util.LinkedList import java.util.logging.Level +import java.util.logging.Logger /** * Represents a locally stored calendar, containing [AndroidEvent]s (whose data objects are [Event]s). @@ -22,16 +31,19 @@ import java.util.logging.Level * database to store the events. */ abstract class AndroidCalendar( - val account: Account, - val provider: ContentProviderClient, - val eventFactory: AndroidEventFactory, + val account: Account, + val provider: ContentProviderClient, + val eventFactory: AndroidEventFactory, - /** the calendar ID ([Calendars._ID]) **/ - val id: Long + /** the calendar ID ([Calendars._ID]) **/ + val id: Long ) { companion object { - + + private val logger + get() = Logger.getLogger(AndroidCalendar::class.java.name) + /** * Recommended initial values when creating Android [Calendars]. */ @@ -58,7 +70,7 @@ abstract class AndroidCalendar( info.putAll(calendarBaseValues) - Ical4Android.log.log(Level.FINE, "Creating local calendar", info) + logger.log(Level.FINE, "Creating local calendar", info) return provider.insert(Calendars.CONTENT_URI.asSyncAdapter(account), info) ?: throw Exception("Couldn't create calendar: provider returned null") } @@ -70,7 +82,7 @@ abstract class AndroidCalendar( return } - Ical4Android.log.info("Inserting event colors for account $account") + logger.info("Inserting event colors for account $account") val values = ContentValues(5) values.put(Colors.ACCOUNT_NAME, account.name) values.put(Colors.ACCOUNT_TYPE, account.type) @@ -81,13 +93,13 @@ abstract class AndroidCalendar( try { provider.insert(Colors.CONTENT_URI.asSyncAdapter(account), values) } catch(e: Exception) { - Ical4Android.log.log(Level.WARNING, "Couldn't insert event color: ${color.name}", e) + logger.log(Level.WARNING, "Couldn't insert event color: ${color.name}", e) } } } fun removeColors(provider: ContentProviderClient, account: Account) { - Ical4Android.log.info("Removing event colors from account $account") + logger.info("Removing event colors from account $account") // unassign colors from events /* ANDROID STRANGENESS: @@ -146,6 +158,7 @@ abstract class AndroidCalendar( } + var name: String? = null var displayName: String? = null var color: Int? = null @@ -154,7 +167,19 @@ abstract class AndroidCalendar( var ownerAccount: String? = null + var syncId: String? = null + + /** + * Sets the calendar properties ([name], [displayName] etc.) from the passed argument, + * which is usually directly taken from the Calendar Provider. + * + * Called when an instance is created from a Calendar Provider data row, for example + * using [find]. + * + * @param info values from Calendar Provider + */ + @CallSuper protected open fun populate(info: ContentValues) { name = info.getAsString(Calendars.NAME) displayName = info.getAsString(Calendars.CALENDAR_DISPLAY_NAME) @@ -165,17 +190,24 @@ abstract class AndroidCalendar( isVisible = info.getAsInteger(Calendars.VISIBLE) != 0 ownerAccount = info.getAsString(Calendars.OWNER_ACCOUNT) + + syncId = info.getAsString(Calendars._SYNC_ID) } fun update(info: ContentValues): Int { - Ical4Android.log.log(Level.FINE, "Updating local calendar (#$id)", info) + logger.log(Level.FINE, "Updating local calendar (#$id)", info) return provider.update(calendarSyncURI(), info, null, null) } - fun delete(): Int { - Ical4Android.log.log(Level.FINE, "Deleting local calendar (#$id)") - return provider.delete(calendarSyncURI(), null, null) + /** + * Deletes this calendar from the local calendar provider. + * + * @return `true` if the calendar was deleted, `false` otherwise (like it was not there before the call) + */ + fun delete(): Boolean { + logger.log(Level.FINE, "Deleting local calendar (#$id)") + return provider.delete(calendarSyncURI(), null, null) > 0 } @@ -204,4 +236,4 @@ abstract class AndroidCalendar( fun calendarSyncURI() = ContentUris.withAppendedId(Calendars.CONTENT_URI, id).asSyncAdapter(account) -} +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt new file mode 100644 index 00000000..2b5602da --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt @@ -0,0 +1,16 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.accounts.Account +import android.content.ContentProviderClient + +interface AndroidCalendarFactory> { + + fun newInstance(account: Account, provider: ContentProviderClient, id: Long): T + +} diff --git a/src/main/java/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt similarity index 68% rename from src/main/java/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt index 14fc31d0..84b66715 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCompatTimeZoneRegistry.kt @@ -1,13 +1,29 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package at.bitfire.ical4android -import net.fortuna.ical4j.model.* +import java.time.ZoneId +import java.util.logging.Logger +import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistry +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.TimeZoneRegistryImpl import net.fortuna.ical4j.model.component.VTimeZone import net.fortuna.ical4j.model.property.TzId -import java.time.ZoneId /** - * The purpose of this class is that if a time zone has a different name in ical4j and Android, - * it should use the Android name. + * Wrapper around default [TimeZoneRegistry] that uses the Android name if a time zone has a + * different name in ical4j and Android. + * + * **This time zone registry is set as default registry for ical4android projects in + * resources/ical4j.properties.** * * For instance, if a time zone is known as "Europe/Kyiv" (with alias "Europe/Kiev") in ical4j * and only "Europe/Kiev" in Android, this registry behaves like the default [TimeZoneRegistryImpl], @@ -18,6 +34,9 @@ class AndroidCompatTimeZoneRegistry( private val base: TimeZoneRegistry ): TimeZoneRegistry by base { + private val logger + get() = Logger.getLogger(javaClass.name) + /** * Gets the time zone for a given ID. * @@ -32,9 +51,8 @@ class AndroidCompatTimeZoneRegistry( * @return time zone */ override fun getTimeZone(id: String): TimeZone? { - val tz: TimeZone? = base.getTimeZone(id) - if (tz == null) // ical4j doesn't know time zone, return null - return null + val tz: TimeZone = base.getTimeZone(id) + ?: return null // ical4j doesn't know time zone, return null // check whether time zone is available on Android, too val androidTzId = @@ -56,14 +74,11 @@ class AndroidCompatTimeZoneRegistry( but most Android devices don't now Europe/Kyiv yet. */ if (tz.id != androidTzId) { - Ical4Android.log.warning("Using Android TZID $androidTzId instead of ical4j ${tz.id}") + logger.fine("Using ical4j timezone ${tz.id} data to construct Android timezone $androidTzId") // create a copy of the VTIMEZONE so that we don't modify the original registry values (which are not immutable) val vTimeZone = tz.vTimeZone - val newVTimeZoneProperties = PropertyList(vTimeZone.properties) - newVTimeZoneProperties.removeAll { property -> - property is TzId - } + val newVTimeZoneProperties = PropertyList() newVTimeZoneProperties += TzId(androidTzId) return TimeZone(VTimeZone( newVTimeZoneProperties, @@ -76,7 +91,7 @@ class AndroidCompatTimeZoneRegistry( class Factory : TimeZoneRegistryFactory() { - override fun createRegistry(): TimeZoneRegistry { + override fun createRegistry(): AndroidCompatTimeZoneRegistry { val ical4jRegistry = DefaultTimeZoneRegistryFactory().createRegistry() return AndroidCompatTimeZoneRegistry(ical4jRegistry) } diff --git a/src/main/java/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt similarity index 80% rename from src/main/java/at/bitfire/ical4android/AndroidEvent.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt index b4823c13..d8b00029 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidEvent.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -10,15 +12,20 @@ import android.content.ContentValues import android.content.EntityIterator import android.net.Uri import android.os.RemoteException -import android.provider.CalendarContract.* +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Colors +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.EventsEntity +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders import android.util.Patterns import androidx.annotation.CallSuper import at.bitfire.ical4android.BatchOperation.CpoBuilder import at.bitfire.ical4android.util.AndroidTimeUtils import at.bitfire.ical4android.util.DateUtils -import at.bitfire.ical4android.util.MiscUtils -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.removeBlankStrings +import at.bitfire.ical4android.util.MiscUtils.toValues import at.bitfire.ical4android.util.TimeApiExtensions import at.bitfire.ical4android.util.TimeApiExtensions.requireZoneId import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate @@ -27,20 +34,43 @@ import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime -import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.* -import net.fortuna.ical4j.model.property.* +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.ExRule +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary import net.fortuna.ical4j.util.TimeZones import java.io.FileNotFoundException import java.net.URI import java.net.URISyntaxException -import java.time.* import java.time.Duration +import java.time.Instant import java.time.Period -import java.util.* +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.Locale import java.util.logging.Level +import java.util.logging.Logger /** * Stores and retrieves VEVENT iCalendar objects (represented as [Event]s) to/from the @@ -52,7 +82,7 @@ import java.util.logging.Level * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. */ abstract class AndroidEvent( - val calendar: AndroidCalendar + val calendar: AndroidCalendar ) { companion object { @@ -68,16 +98,27 @@ abstract class AndroidEvent( * * Example: `Cat1\Cat2` */ - const val MIMETYPE_CATEGORIES = "categories" + const val EXTNAME_CATEGORIES = "categories" const val CATEGORIES_SEPARATOR = '\\' + /** + * Google Calendar uses an extended property called `iCalUid` for storing the event's UID, instead of the + * standard [Events.UID_2445]. + * + * @see GitHub Issue + */ + const val EXTNAME_ICAL_UID = "iCalUid" + /** * VEVENT URL is stored as an extended property with this [ExtendedProperties.NAME]. * The URL is directly put into [ExtendedProperties.VALUE]. */ - const val MIMETYPE_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" + const val EXTNAME_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" + } + protected val logger: Logger by lazy { Logger.getLogger(AndroidEvent::class.java.name) } + var id: Long? = null protected set @@ -99,17 +140,22 @@ abstract class AndroidEvent( this.event = event } - var event: Event? = null - /** - * This getter returns the full event data, either from [event] or, if [event] is null, by reading event - * number [id] from the Android calendar storage - * @throws IllegalArgumentException if event has not been saved yet - * @throws FileNotFoundException if there's no event with [id] in the calendar storage - * @throws RemoteException on calendar provider errors - */ + private var _event: Event? = null + + /** + * Returns the full event data, either from [event] or, if [event] is null, by reading event + * number [id] from the Android calendar storage + * @throws IllegalArgumentException if event has not been saved yet + * @throws FileNotFoundException if there's no event with [id] in the calendar storage + * @throws RemoteException on calendar provider errors + */ + var event: Event? + private set(value) { + _event = value + } get() { - if (field != null) - return field + if (_event != null) + return _event val id = requireNotNull(id) var iterEvents: EntityIterator? = null @@ -126,16 +172,16 @@ abstract class AndroidEvent( // create new Event which will be populated val newEvent = Event() - field = newEvent + _event = newEvent // calculate some scheduling properties val groupScheduled = e.subValues.any { it.uri == Attendees.CONTENT_URI } val isOrganizer = (e.entityValues.getAsInteger(Events.IS_ORGANIZER) ?: 0) != 0 - populateEvent(MiscUtils.removeEmptyStrings(e.entityValues), groupScheduled) + populateEvent(e.entityValues.removeBlankStrings(), groupScheduled) for (subValue in e.subValues) { - val subValues = MiscUtils.removeEmptyStrings(subValue.values) + val subValues = subValue.values.removeBlankStrings() when (subValue.uri) { Attendees.CONTENT_URI -> populateAttendee(subValues, isOrganizer) Reminders.CONTENT_URI -> populateReminder(subValues) @@ -150,7 +196,7 @@ abstract class AndroidEvent( /* Populating event has been interrupted by an exception, so we reset the event to avoid an inconsistent state. This also ensures that the exception will be thrown again on the next get() call. */ - field = null + _event = null throw e } finally { iterEvents?.close() @@ -166,7 +212,7 @@ abstract class AndroidEvent( @Suppress("UNUSED_VALUE") @CallSuper protected open fun populateEvent(row: ContentValues, groupScheduled: Boolean) { - Ical4Android.log.log(Level.FINE, "Read event entity from calender provider", row) + logger.log(Level.FINE, "Read event entity from calender provider", row) val event = requireNotNull(event) row.getAsString(Events.MUTATORS)?.let { strPackages -> @@ -204,10 +250,10 @@ abstract class AndroidEvent( if (tsEnd != null) { when { tsEnd < tsStart -> - Ical4Android.log.warning("dtEnd $tsEnd (allDay) < dtStart $tsStart (allDay), ignoring") + logger.warning("dtEnd $tsEnd (allDay) < dtStart $tsStart (allDay), ignoring") tsEnd == tsStart -> - Ical4Android.log.fine("dtEnd $tsEnd (allDay) = dtStart, won't generate DTEND property") + logger.fine("dtEnd $tsEnd (allDay) = dtStart, won't generate DTEND property") else /* tsEnd > tsStart */ -> event.dtEnd = DtEnd(Date(tsEnd)) @@ -244,9 +290,9 @@ abstract class AndroidEvent( if (tsEnd != null) { if (tsEnd < tsStart) - Ical4Android.log.warning("dtEnd $tsEnd < dtStart $tsStart, ignoring") + logger.warning("dtEnd $tsEnd < dtStart $tsStart, ignoring") /*else if (tsEnd == tsStart) // iCloud sends 404 when it receives an iCalendar with DTSTART but without DTEND - Ical4Android.log.fine("dtEnd $tsEnd == dtStart, won't generate DTEND property")*/ + logger.fine("dtEnd $tsEnd == dtStart, won't generate DTEND property")*/ else /* tsEnd > tsStart */ { val endTz = row.getAsString(Events.EVENT_END_TIMEZONE)?.let { tzId -> DateUtils.ical4jTimeZone(tzId) @@ -284,20 +330,27 @@ abstract class AndroidEvent( event.exDates += exDate } } catch (e: Exception) { - Ical4Android.log.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) + logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) } + event.uid = row.getAsString(Events.UID_2445) event.summary = row.getAsString(Events.TITLE) event.location = row.getAsString(Events.EVENT_LOCATION) event.description = row.getAsString(Events.DESCRIPTION) - row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> - try { - event.color = Css3Color.valueOf(name) - } catch (e: IllegalArgumentException) { - Ical4Android.log.warning("Ignoring unknown color $name from Calendar Provider") + // color can be specified as RGB value and/or as index key (CSS3 color of AndroidCalendar) + event.color = + row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> // try color key first + try { + Css3Color.valueOf(name) + } catch (_: IllegalArgumentException) { + logger.warning("Ignoring unknown color name \"$name\"") + null + } + } ?: + row.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value + Css3Color.entries.firstOrNull { it.argb == color } } - } // status when (row.getAsInteger(Events.STATUS)) { @@ -312,11 +365,11 @@ abstract class AndroidEvent( // scheduling if (groupScheduled) { // ORGANIZER must only be set for group-scheduled events (= events with attendees) - if (row.containsKey(Events.ORGANIZER) && groupScheduled) + if (row.containsKey(Events.ORGANIZER)) try { event.organizer = Organizer(URI("mailto", row.getAsString(Events.ORGANIZER), null)) } catch (e: URISyntaxException) { - Ical4Android.log.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) + logger.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) } } @@ -348,7 +401,7 @@ abstract class AndroidEvent( } protected open fun populateAttendee(row: ContentValues, isOrganizer: Boolean) { - Ical4Android.log.log(Level.FINE, "Read event attendee from calender provider", row) + logger.log(Level.FINE, "Read event attendee from calender provider", row) try { val attendee: Attendee @@ -384,12 +437,12 @@ abstract class AndroidEvent( event!!.attendees.add(attendee) } catch (e: URISyntaxException) { - Ical4Android.log.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e) + logger.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e) } } protected open fun populateReminder(row: ContentValues) { - Ical4Android.log.log(Level.FINE, "Read event reminder from calender provider", row) + logger.log(Level.FINE, "Read event reminder from calender provider", row) val event = requireNotNull(event) val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) @@ -407,7 +460,7 @@ abstract class AndroidEvent( // account name (should be account owner's email address) props += Attendee(URI("mailto", calendar.account.name, null)) } else { - Ical4Android.log.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY") + logger.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY") props += Action.DISPLAY props += Description(event.summary) } @@ -423,28 +476,33 @@ abstract class AndroidEvent( } protected open fun populateExtended(row: ContentValues) { - val mimeType = row.getAsString(ExtendedProperties.NAME) + val name = row.getAsString(ExtendedProperties.NAME) val rawValue = row.getAsString(ExtendedProperties.VALUE) - Ical4Android.log.log(Level.FINE, "Read extended property from calender provider", arrayOf(mimeType, rawValue)) + logger.log(Level.FINE, "Read extended property from calender provider", arrayOf(name, rawValue)) val event = requireNotNull(event) try { - when (mimeType) { - MIMETYPE_CATEGORIES -> + when (name) { + EXTNAME_CATEGORIES -> event.categories += rawValue.split(CATEGORIES_SEPARATOR) - MIMETYPE_URL -> + EXTNAME_URL -> try { event.url = URI(rawValue) - } catch(e: URISyntaxException) { - Ical4Android.log.warning("Won't process invalid local URL: $rawValue") + } catch(_: URISyntaxException) { + logger.warning("Won't process invalid local URL: $rawValue") } + EXTNAME_ICAL_UID -> + // only consider iCalUid when there's no uid + if (event.uid == null) + event.uid = rawValue + UnknownProperty.CONTENT_ITEM_TYPE -> event.unknownProperties += UnknownProperty.fromJsonString(rawValue) } } catch (e: Exception) { - Ical4Android.log.log(Level.WARNING, "Couldn't parse extended property", e) + logger.log(Level.WARNING, "Couldn't parse extended property", e) } } @@ -486,7 +544,7 @@ abstract class AndroidEvent( event.exceptions += exceptionEvent } } catch (e: Exception) { - Ical4Android.log.log(Level.WARNING, "Couldn't find exception details", e) + logger.log(Level.WARNING, "Couldn't find exception details", e) } } } @@ -558,7 +616,7 @@ abstract class AndroidEvent( retainClassification() // URL event.url?.let { url -> - insertExtendedProperty(batch, idxEvent, MIMETYPE_URL, url.toString()) + insertExtendedProperty(batch, idxEvent, EXTNAME_URL, url.toString()) } // unknown properties event.unknownProperties.forEach { @@ -584,7 +642,7 @@ abstract class AndroidEvent( val recurrenceId = exception.recurrenceId if (recurrenceId == null) { - Ical4Android.log.warning("Ignoring exception of event ${event.uid} without recurrenceId") + logger.warning("Ignoring exception of event ${event.uid} without recurrenceId") continue } @@ -645,9 +703,11 @@ abstract class AndroidEvent( var rebuild = false if (event.status == null) calendar.provider.query(eventSyncURI(), arrayOf(Events.STATUS), null, null, null)?.use { cursor -> - cursor.moveToNext() - if (!cursor.isNull(0)) // Events.STATUS != null - rebuild = true + if (cursor.moveToNext()) { + val statusIndex = cursor.getColumnIndexOrThrow(Events.STATUS) + if (!cursor.isNull(statusIndex)) + rebuild = true + } } if (rebuild) { // delete whole event and insert updated event @@ -667,8 +727,14 @@ abstract class AndroidEvent( .enqueue(CpoBuilder .newDelete(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) .withSelection( - "${ExtendedProperties.EVENT_ID}=? AND ${ExtendedProperties.NAME} IN (?,?,?)", - arrayOf(existingId.toString(), MIMETYPE_CATEGORIES, MIMETYPE_URL, UnknownProperty.CONTENT_ITEM_TYPE) + "${ExtendedProperties.EVENT_ID}=? AND ${ExtendedProperties.NAME} IN (?,?,?,?)", + arrayOf( + existingId.toString(), + EXTNAME_CATEGORIES, + EXTNAME_ICAL_UID, // UID is stored in UID_2445, don't leave iCalUid rows in events that we have written + EXTNAME_URL, + UnknownProperty.CONTENT_ITEM_TYPE + ) )) addOrUpdateRows(batch) @@ -790,15 +856,25 @@ abstract class AndroidEvent( builder.withValue(Events.RRULE, null) if (event.rDates.isNotEmpty()) { - for (rDate in event.rDates) - AndroidTimeUtils.androidifyTimeZone(rDate) + // ignore RDATEs when there's also an infinite RRULE [https://issuetracker.google.com/issues/216374004] + val infiniteRrule = event.rRules.any { rRule -> + rRule.recur.count == -1 && // no COUNT AND + rRule.recur.until == null // no UNTIL + } - // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] - val listWithDtStart = DateList() - listWithDtStart.add(dtStart.date) - event.rDates.addFirst(RDate(listWithDtStart)) + if (infiniteRrule) + logger.warning("Android can't handle infinite RRULE + RDATE [https://issuetracker.google.com/issues/216374004]; ignoring RDATE(s)") + else { + for (rDate in event.rDates) + AndroidTimeUtils.androidifyTimeZone(rDate) - builder.withValue(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.rDates, allDay)) + // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] + val listWithDtStart = DateList() + listWithDtStart.add(dtStart.date) + event.rDates.addFirst(RDate(listWithDtStart)) + + builder.withValue(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.rDates, dtStart.date)) + } } else builder.withValue(Events.RDATE, null) @@ -810,7 +886,7 @@ abstract class AndroidEvent( if (event.exDates.isNotEmpty()) { for (exDate in event.exDates) AndroidTimeUtils.androidifyTimeZone(exDate) - builder.withValue(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.exDates, allDay)) + builder.withValue(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.exDates, dtStart.date)) } else builder.withValue(Events.EXDATE, null) @@ -858,21 +934,26 @@ abstract class AndroidEvent( .withValue(Events.EXDATE, null) } + builder.withValue(Events.UID_2445, event.uid) builder.withValue(Events.TITLE, event.summary) builder.withValue(Events.EVENT_LOCATION, event.location) builder.withValue(Events.DESCRIPTION, event.description) - builder.withValue(Events.EVENT_COLOR_KEY, event.color?.let { color -> - val colorName = color.name + + val color = event.color + if (color != null) { // set event color (if it's available for this account) calendar.provider.query(Colors.CONTENT_URI.asSyncAdapter(calendar.account), arrayOf(Colors.COLOR_KEY), - "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(colorName), null)?.use { cursor -> + "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(color.name), null)?.use { cursor -> if (cursor.moveToNext()) - return@let colorName + builder.withValue(Events.EVENT_COLOR_KEY, color.name) else - Ical4Android.log.fine("Ignoring event color: $colorName (not available for this account)") + logger.fine("Ignoring event color \"${color.name}\" (not available for this account)") } - null - }) + } else { + // reset color index and value + builder .withValue(Events.EVENT_COLOR_KEY, null) + .withValue(Events.EVENT_COLOR, null) + } // scheduling val groupScheduled = event.attendees.isNotEmpty() @@ -886,7 +967,7 @@ abstract class AndroidEvent( organizer.getParameter(Parameter.EMAIL)?.value if (email != null) return@let email - Ical4Android.log.warning("Ignoring ORGANIZER without email address (not supported by Android)") + logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") null } ?: calendar.ownerAccount) @@ -906,7 +987,8 @@ abstract class AndroidEvent( builder .withValue(Events.AVAILABILITY, if (event.opaque) Events.AVAILABILITY_BUSY else Events.AVAILABILITY_FREE) .withValue(Events.ACCESS_LEVEL, when (event.classification) { - null, Clazz.PUBLIC -> Events.ACCESS_PUBLIC + null -> Events.ACCESS_DEFAULT + Clazz.PUBLIC -> Events.ACCESS_PUBLIC Clazz.CONFIDENTIAL -> Events.ACCESS_CONFIDENTIAL else /* including Events.ACCESS_PRIVATE */ -> Events.ACCESS_PRIVATE }) @@ -919,12 +1001,12 @@ abstract class AndroidEvent( val method = when (alarm.action?.value?.uppercase(Locale.ROOT)) { Action.DISPLAY.value, - Action.AUDIO.value -> Reminders.METHOD_ALERT + Action.AUDIO.value -> Reminders.METHOD_ALERT // will trigger an alarm on the Android device // Note: The calendar provider doesn't support saving specific attendees for email reminders. Action.EMAIL.value -> Reminders.METHOD_EMAIL - else -> Reminders.METHOD_DEFAULT + else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device } val minutes = ICalendar.vAlarmToMin(alarm, event!!, false)?.second ?: Reminders.MINUTES_DEFAULT @@ -971,12 +1053,12 @@ abstract class AndroidEvent( batch.enqueue(builder) } - protected open fun insertExtendedProperty(batch: BatchOperation, idxEvent: Int?, mimeType: String, value: String) { + protected open fun insertExtendedProperty(batch: BatchOperation, idxEvent: Int?, name: String, value: String) { val builder = CpoBuilder - .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(ExtendedProperties.EVENT_ID, idxEvent) - .withValue(ExtendedProperties.NAME, mimeType) - .withValue(ExtendedProperties.VALUE, value) + .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(ExtendedProperties.EVENT_ID, idxEvent) + .withValue(ExtendedProperties.NAME, name) + .withValue(ExtendedProperties.VALUE, value) batch.enqueue(builder) } @@ -986,12 +1068,16 @@ abstract class AndroidEvent( // drop occurrences of CATEGORIES_SEPARATOR in category names category.filter { it != CATEGORIES_SEPARATOR } } - insertExtendedProperty(batch, idxEvent, MIMETYPE_CATEGORIES, rawCategories) + insertExtendedProperty(batch, idxEvent, EXTNAME_CATEGORIES, rawCategories) } protected open fun insertUnknownProperty(batch: BatchOperation, idxEvent: Int?, property: Property) { + if (property.value == null) { + logger.warning("Ignoring unknown property with null value") + return + } if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { - Ical4Android.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") return } @@ -1031,6 +1117,7 @@ abstract class AndroidEvent( return ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(calendar.account) } - override fun toString() = MiscUtils.reflectionToString(this) + @CallSuper + override fun toString(): String = "AndroidEvent(calendar=$calendar, id=$id, event=$_event)" } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt new file mode 100644 index 00000000..795a3f6b --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt @@ -0,0 +1,15 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.content.ContentValues + +interface AndroidEventFactory { + + fun fromProvider(calendar: AndroidCalendar, values: ContentValues): T + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/AttendeeMappings.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt similarity index 95% rename from src/main/java/at/bitfire/ical4android/AttendeeMappings.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt index 69d4bafc..61117ea5 100644 --- a/src/main/java/at/bitfire/ical4android/AttendeeMappings.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android diff --git a/src/main/java/at/bitfire/ical4android/BatchOperation.kt b/lib/src/main/kotlin/at/bitfire/ical4android/BatchOperation.kt similarity index 58% rename from src/main/java/at/bitfire/ical4android/BatchOperation.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/BatchOperation.kt index 9e382d14..a71db9a2 100644 --- a/src/main/java/at/bitfire/ical4android/BatchOperation.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/BatchOperation.kt @@ -1,20 +1,37 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android -import android.content.* +import android.content.ContentProviderClient +import android.content.ContentProviderOperation +import android.content.ContentProviderResult +import android.content.ContentUris +import android.content.OperationApplicationException import android.net.Uri import android.os.RemoteException import android.os.TransactionTooLargeException -import java.util.* +import java.util.LinkedList import java.util.logging.Level +import java.util.logging.Logger class BatchOperation( - private val providerClient: ContentProviderClient + private val providerClient: ContentProviderClient, + private val maxOperationsPerYieldPoint: Int? = null ) { + companion object { + + /** Maximum number of operations per yield point in task providers that are based on SQLiteContentProvider. */ + const val TASKS_OPERATIONS_PER_YIELD_POINT = 499 + + } + + private val logger = Logger.getLogger(javaClass.name) + private val queue = LinkedList() private var results = arrayOfNulls(0) @@ -30,30 +47,35 @@ class BatchOperation( * Commits all operations from [queue] and then empties the queue. * * @return number of affected rows + * + * @throws RemoteException on calendar provider errors. In case of [android.os.DeadObjectException], + * the provider has probably been killed/crashed or the calling process is cached and thus IPC is frozen (Android 14+). + * + * @throws CalendarStorageException if + * + * - the transaction is too large and can't be split (wrapped [TransactionTooLargeException]) + * - the batch can't be processed (wrapped [OperationApplicationException]) + * - the content provider throws a [RuntimeException] (will be wrapped) */ fun commit(): Int { var affected = 0 - if (!queue.isEmpty()) - try { - if (Ical4Android.log.isLoggable(Level.FINE)) { - Ical4Android.log.log(Level.FINE, "Committing ${queue.size} operations:") - for ((idx, op) in queue.withIndex()) - Ical4Android.log.log(Level.FINE, "#$idx: ${op.build()}") - } - - results = arrayOfNulls(queue.size) - runBatch(0, queue.size) + if (!queue.isEmpty()) { + if (logger.isLoggable(Level.FINE)) { + logger.log(Level.FINE, "Committing ${queue.size} operations:") + for ((idx, op) in queue.withIndex()) + logger.log(Level.FINE, "#$idx: ${op.build()}") + } - for (result in results.filterNotNull()) - when { - result.count != null -> affected += result.count ?: 0 - result.uri != null -> affected += 1 - } - Ical4Android.log.fine("… $affected record(s) affected") + results = arrayOfNulls(queue.size) + runBatch(0, queue.size) - } catch(e: Exception) { - throw CalendarStorageException("Couldn't apply batch operation", e) - } + for (result in results.filterNotNull()) + when { + result.count != null -> affected += result.count ?: 0 + result.uri != null -> affected += 1 + } + logger.fine("… $affected record(s) affected") + } queue.clear() return affected @@ -64,12 +86,19 @@ class BatchOperation( /** * Runs a subset of the operations in [queue] using [providerClient] in a transaction. - * Catches [TransactionTooLargeException] and splits the operations accordingly. + * Catches [TransactionTooLargeException] and splits the operations accordingly (if possible). + * * @param start index of first operation which will be run (inclusive) * @param end index of last operation which will be run (exclusive!) - * @throws RemoteException on calendar provider errors - * @throws OperationApplicationException when the batch can't be processed - * @throws CalendarStorageException if the transaction is too large + * + * @throws RemoteException on calendar provider errors. In case of [android.os.DeadObjectException], + * the provider has probably been killed/crashed or the calling process is cached and thus IPC is frozen (Android 14+). + * + * @throws CalendarStorageException if + * + * - the transaction is too large and can't be split (wrapped [TransactionTooLargeException]) + * - the batch can't be processed (wrapped [OperationApplicationException]) + * - the content provider throws a [RuntimeException] (will be wrapped) */ private fun runBatch(start: Int, end: Int) { if (end == start) @@ -77,20 +106,26 @@ class BatchOperation( try { val ops = toCPO(start, end) - Ical4Android.log.fine("Running ${ops.size} operations ($start .. ${end-1})") val partResults = providerClient.applyBatch(ops) val n = end - start if (partResults.size != n) - Ical4Android.log.warning("Batch operation returned only ${partResults.size} instead of $n results") + logger.warning("Batch operation returned only ${partResults.size} instead of $n results") System.arraycopy(partResults, 0, results, start, partResults.size) + + } catch (e: OperationApplicationException) { + throw CalendarStorageException("Couldn't apply batch operation", e) + + } catch (e: RuntimeException) { + throw CalendarStorageException("Content provider threw a runtime exception", e) + } catch(e: TransactionTooLargeException) { if (end <= start + 1) // only one operation, can't be split - throw CalendarStorageException("Can't transfer data to content provider (data row too large)") + throw CalendarStorageException("Can't transfer data to content provider (too large data row can't be split)", e) - Ical4Android.log.warning("Transaction too large, splitting (losing atomicity)") + logger.warning("Transaction too large, splitting (losing atomicity)") val mid = start + (end - start)/2 runBatch(start, mid) @@ -107,6 +142,7 @@ class BatchOperation( * 2. If a back reference points to a row outside of start/end, * replace it by the actual result, which has already been calculated. */ + var currentIdx = 0 for (cpoBuilder in queue.subList(start, end)) { for ((backrefKey, backref) in cpoBuilder.valueBackrefs) { val originalIdx = backref.originalIndex @@ -122,6 +158,11 @@ class BatchOperation( backref.setIndex(originalIdx - start) } + // Set a possible yield point every MAX_OPERATIONS_PER_YIELD_POINT operations for SQLiteContentProvider + currentIdx += 1 + if (maxOperationsPerYieldPoint != null && currentIdx.mod(maxOperationsPerYieldPoint) == 0) + cpoBuilder.withYieldAllowed() + cpo += cpoBuilder.build() } return cpo @@ -129,10 +170,10 @@ class BatchOperation( class BackReference( - /** index of the referenced row in the original, nonsplitted transaction */ - val originalIndex: Int + /** index of the referenced row in the original, non-splitted transaction */ + val originalIndex: Int ) { - /** overriden index, i.e. index within the splitted transaction */ + /** overridden index, i.e. index within the splitted transaction */ private var index: Int? = null /** @@ -156,8 +197,8 @@ class BatchOperation( * value back references. */ class CpoBuilder private constructor( - val uri: Uri, - val type: Type + val uri: Uri, + val type: Type ) { enum class Type { INSERT, UPDATE, DELETE } @@ -171,11 +212,13 @@ class BatchOperation( } - var selection: String? = null - var selectionArguments: Array? = null + private var selection: String? = null + private var selectionArguments: Array? = null + + internal val values = mutableMapOf() + internal val valueBackrefs = mutableMapOf() - val values = mutableMapOf() - val valueBackrefs = mutableMapOf() + private var yieldAllowed = false fun withSelection(select: String, args: Array): CpoBuilder { @@ -200,6 +243,10 @@ class BatchOperation( return this } + fun withYieldAllowed() { + yieldAllowed = true + } + fun remove(key: String): CpoBuilder { values.remove(key) return this @@ -220,6 +267,9 @@ class BatchOperation( for ((key, backref) in valueBackrefs) builder.withValueBackReference(key, backref.getIndex()) + if (yieldAllowed) + builder.withYieldAllowed(true) + return builder.build() } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/CalendarStorageException.kt b/lib/src/main/kotlin/at/bitfire/ical4android/CalendarStorageException.kt new file mode 100644 index 00000000..3e2e07d7 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/CalendarStorageException.kt @@ -0,0 +1,19 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +/** + * Indicates a problem with a calendar storage operation, like when a row can't be inserted or updated. + * + * Should not be used to wrap [android.os.RemoteException]. + */ +class CalendarStorageException: Exception { + + constructor(message: String): super(message) + constructor(message: String, ex: Throwable): super(message, ex) + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/Css3Color.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Css3Color.kt similarity index 94% rename from src/main/java/at/bitfire/ical4android/Css3Color.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/Css3Color.kt index 8a5443f0..f546a01c 100644 --- a/src/main/java/at/bitfire/ical4android/Css3Color.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Css3Color.kt @@ -1,10 +1,13 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import android.graphics.Color +import java.util.logging.Logger import kotlin.math.sqrt /** @@ -166,6 +169,9 @@ enum class Css3Color(val argb: Int) { companion object { + private val logger + get() = Logger.getLogger(Css3Color::javaClass.name) + /** * Parses the given color either as CSS3 color name or as (A)RGB hex value. * @@ -177,7 +183,7 @@ enum class Css3Color(val argb: Int) { try { Color.parseColor(color) } catch(e: Exception) { - Ical4Android.log.warning("Invalid color value: $color") + logger.warning("Invalid color value: $color") null } @@ -191,7 +197,7 @@ enum class Css3Color(val argb: Int) { try { valueOf(name.lowercase()) } catch (e: IllegalArgumentException) { - Ical4Android.log.warning("Invalid color name: $name") + logger.warning("Invalid color name: $name") null } diff --git a/src/main/java/at/bitfire/ical4android/AndroidTask.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt similarity index 83% rename from src/main/java/at/bitfire/ical4android/AndroidTask.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt index d00faf07..afb475b8 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidTask.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTask.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -12,41 +14,60 @@ import androidx.annotation.CallSuper import at.bitfire.ical4android.BatchOperation.CpoBuilder import at.bitfire.ical4android.util.AndroidTimeUtils import at.bitfire.ical4android.util.DateUtils -import at.bitfire.ical4android.util.MiscUtils -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues -import net.fortuna.ical4j.model.* +import at.bitfire.ical4android.util.MiscUtils.toValues import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.Email import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.property.* +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Trigger import net.fortuna.ical4j.util.TimeZones import org.dmfs.tasks.contract.TaskContract.Properties -import org.dmfs.tasks.contract.TaskContract.Property.* +import org.dmfs.tasks.contract.TaskContract.Property.Alarm +import org.dmfs.tasks.contract.TaskContract.Property.Category +import org.dmfs.tasks.contract.TaskContract.Property.Comment +import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.Tasks import java.io.FileNotFoundException import java.net.URISyntaxException import java.time.ZoneId -import java.util.* +import java.util.Locale import java.util.logging.Level +import java.util.logging.Logger /** * Stores and retrieves VTODO iCalendar objects (represented as [Task]s) to/from the - * OpenTasks provider. + * tasks.org-content provider (currently tasks.org and OpenTasks). * * Extend this class to process specific fields of the task. * * The SEQUENCE field is stored in [Tasks.SYNC_VERSION], so don't use [Tasks.SYNC_VERSION] * for anything else. - * */ -abstract class AndroidTask( - private val taskListOpt: AndroidTaskList? +abstract class DmfsTask( + private val taskListOpt: DmfsTaskList? ) { - val taskList: AndroidTaskList + val taskList: DmfsTaskList get() = taskListOpt!! companion object { @@ -55,14 +76,16 @@ abstract class AndroidTask( val utcTimeZone by lazy { DateUtils.ical4jTimeZone(TimeZones.UTC_ID) } } + protected val logger = Logger.getLogger(javaClass.name) + var id: Long? = null - constructor(taskList: AndroidTaskList, values: ContentValues): this(taskList) { + constructor(taskList: DmfsTaskList, values: ContentValues): this(taskList) { id = values.getAsLong(Tasks._ID) } - constructor(taskList: AndroidTaskList, task: Task): this(taskList) { + constructor(taskList: DmfsTaskList, task: Task): this(taskList) { this.task = task } @@ -81,7 +104,7 @@ abstract class AndroidTask( val id = requireNotNull(id) try { - val client = taskList.provider.client + val client = taskList.provider client.query(taskSyncURI(true), null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { // create new Task which will be populated @@ -89,7 +112,7 @@ abstract class AndroidTask( field = newTask val values = cursor.toValues(true) - Ical4Android.log.log(Level.FINER, "Found task", values) + logger.log(Level.FINER, "Found task", values) populateTask(values) if (values.containsKey(Properties.PROPERTY_ID)) { @@ -143,14 +166,14 @@ abstract class AndroidTask( task.sequence = values.getAsInteger(Tasks.SYNC_VERSION) task.summary = values.getAsString(Tasks.TITLE) task.location = values.getAsString(Tasks.LOCATION) - task.userAgents += taskList.provider.name.packageName + task.userAgents += taskList.providerName.packageName values.getAsString(Tasks.GEO)?.let { geo -> val (lng, lat) = geo.split(',') try { task.geoPosition = Geo(lat.toBigDecimal(), lng.toBigDecimal()) } catch (e: NumberFormatException) { - Ical4Android.log.warning("Invalid GEO value: $geo") + logger.warning("Invalid GEO value: $geo") } } @@ -162,7 +185,7 @@ abstract class AndroidTask( try { task.organizer = Organizer("mailto:$it") } catch(e: URISyntaxException) { - Ical4Android.log.log(Level.WARNING, "Invalid ORGANIZER email", e) + logger.log(Level.WARNING, "Invalid ORGANIZER email", e) } } @@ -185,7 +208,7 @@ abstract class AndroidTask( else -> Status.VTODO_NEEDS_ACTION } - val allDay = values.getAsInteger(Tasks.IS_ALLDAY) ?: 0 != 0 + val allDay = (values.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0 val tzID = values.getAsString(Tasks.TZ) val tz = tzID?.let { DateUtils.ical4jTimeZone(it) } @@ -241,7 +264,7 @@ abstract class AndroidTask( } protected open fun populateProperty(row: ContentValues) { - Ical4Android.log.log(Level.FINER, "Found property", row) + logger.log(Level.FINER, "Found property", row) val task = requireNotNull(task) when (val type = row.getAsString(Properties.MIMETYPE)) { @@ -249,12 +272,14 @@ abstract class AndroidTask( populateAlarm(row) Category.CONTENT_ITEM_TYPE -> task.categories += row.getAsString(Category.CATEGORY_NAME) + Comment.CONTENT_ITEM_TYPE -> + task.comment = row.getAsString(Comment.COMMENT) Relation.CONTENT_ITEM_TYPE -> populateRelatedTo(row) UnknownProperty.CONTENT_ITEM_TYPE -> task.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(UNKNOWN_PROPERTY_DATA)) else -> - Ical4Android.log.warning("Found unknown property of type $type") + logger.warning("Found unknown property of type $type") } } @@ -289,7 +314,7 @@ abstract class AndroidTask( protected open fun populateRelatedTo(row: ContentValues) { val uid = row.getAsString(Relation.RELATED_UID) if (uid == null) { - Ical4Android.log.warning("Task relation doesn't refer to same task list; can't be synchronized") + logger.warning("Task relation doesn't refer to same task list; can't be synchronized") return } @@ -310,7 +335,7 @@ abstract class AndroidTask( fun add(): Uri { - val batch = BatchOperation(taskList.provider.client) + val batch = BatchOperation(taskList.provider, BatchOperation.TASKS_OPERATIONS_PER_YIELD_POINT) val builder = CpoBuilder.newInsert(taskList.tasksSyncUri()) buildTask(builder, false) @@ -330,7 +355,7 @@ abstract class AndroidTask( this.task = task val existingId = requireNotNull(id) - val batch = BatchOperation(taskList.provider.client) + val batch = BatchOperation(taskList.provider, BatchOperation.TASKS_OPERATIONS_PER_YIELD_POINT) // remove associated rows which are added later again batch.enqueue(CpoBuilder @@ -347,12 +372,13 @@ abstract class AndroidTask( insertProperties(batch, null) batch.commit() - return ContentUris.withAppendedId(taskList.provider.tasksUri(), existingId) + return ContentUris.withAppendedId(Tasks.getContentUri(taskList.providerName.authority), existingId) } protected open fun insertProperties(batch: BatchOperation, idxTask: Int?) { insertAlarms(batch, idxTask) insertCategories(batch, idxTask) + insertComment(batch, idxTask) insertRelatedTo(batch, idxTask) insertUnknownProperties(batch, idxTask) } @@ -388,7 +414,7 @@ abstract class AndroidTask( .withValue(Alarm.MESSAGE, alarm.description?.value ?: alarm.summary) .withValue(Alarm.ALARM_TYPE, alarmType) - Ical4Android.log.log(Level.FINE, "Inserting alarm", builder.build()) + logger.log(Level.FINE, "Inserting alarm", builder.build()) batch.enqueue(builder) } } @@ -399,11 +425,21 @@ abstract class AndroidTask( .withTaskId(Category.TASK_ID, idxTask) .withValue(Category.MIMETYPE, Category.CONTENT_ITEM_TYPE) .withValue(Category.CATEGORY_NAME, category) - Ical4Android.log.log(Level.FINE, "Inserting category", builder.build()) + logger.log(Level.FINE, "Inserting category", builder.build()) batch.enqueue(builder) } } + protected open fun insertComment(batch: BatchOperation, idxTask: Int?) { + val comment = requireNotNull(task).comment ?: return + val builder = CpoBuilder.newInsert(taskList.tasksPropertiesSyncUri()) + .withTaskId(Comment.TASK_ID, idxTask) + .withValue(Comment.MIMETYPE, Comment.CONTENT_ITEM_TYPE) + .withValue(Comment.COMMENT, comment) + logger.log(Level.FINE, "Inserting comment", builder.build()) + batch.enqueue(builder) + } + protected open fun insertRelatedTo(batch: BatchOperation, idxTask: Int?) { for (relatedTo in requireNotNull(task).relatedTo) { val relType = when ((relatedTo.getParameter(Parameter.RELTYPE) as RelType?)) { @@ -419,7 +455,7 @@ abstract class AndroidTask( .withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE) .withValue(Relation.RELATED_UID, relatedTo.value) .withValue(Relation.RELATED_TYPE, relType) - Ical4Android.log.log(Level.FINE, "Inserting relation", builder.build()) + logger.log(Level.FINE, "Inserting relation", builder.build()) batch.enqueue(builder) } } @@ -427,7 +463,7 @@ abstract class AndroidTask( protected open fun insertUnknownProperties(batch: BatchOperation, idxTask: Int?) { for (property in requireNotNull(task).unknownProperties) { if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { - Ical4Android.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") return } @@ -435,17 +471,13 @@ abstract class AndroidTask( .withTaskId(Properties.TASK_ID, idxTask) .withValue(Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE) .withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property)) - Ical4Android.log.log(Level.FINE, "Inserting unknown property", builder.build()) + logger.log(Level.FINE, "Inserting unknown property", builder.build()) batch.enqueue(builder) } } fun delete(): Int { - try { - return taskList.provider.client.delete(taskSyncURI(), null, null) - } catch(e: RemoteException) { - throw CalendarStorageException("Couldn't delete event", e) - } + return taskList.provider.delete(taskSyncURI(), null, null) } @CallSuper @@ -476,7 +508,7 @@ abstract class AndroidTask( if (email != null) builder.withValue(Tasks.ORGANIZER, email) else - Ical4Android.log.warning("Ignoring ORGANIZER without email address (not supported by Android)") + logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") } builder .withValue(Tasks.PRIORITY, task.priority) @@ -530,7 +562,7 @@ abstract class AndroidTask( null else AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.exDates, if (allDay) null else getTimeZone())) - Ical4Android.log.log(Level.FINE, "Built task object", builder.build()) + logger.log(Level.FINE, "Built task object", builder.build()) } @@ -566,6 +598,4 @@ abstract class AndroidTask( return ContentUris.withAppendedId(taskList.tasksSyncUri(loadProperties), id) } - override fun toString() = MiscUtils.reflectionToString(this) - } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskFactory.kt new file mode 100644 index 00000000..c941ddea --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskFactory.kt @@ -0,0 +1,15 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.content.ContentValues + +interface DmfsTaskFactory { + + fun fromProvider(taskList: DmfsTaskList, values: ContentValues): T + +} diff --git a/src/main/java/at/bitfire/ical4android/AndroidTaskList.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskList.kt similarity index 60% rename from src/main/java/at/bitfire/ical4android/AndroidTaskList.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskList.kt index 8e2a4913..67718ea8 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidTaskList.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskList.kt @@ -1,62 +1,72 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import android.accounts.Account +import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues import android.net.Uri -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter +import androidx.annotation.CallSuper +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.toValues import org.dmfs.tasks.contract.TaskContract import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.TaskLists import org.dmfs.tasks.contract.TaskContract.Tasks import java.io.FileNotFoundException -import java.util.* +import java.util.LinkedList import java.util.logging.Level +import java.util.logging.Logger /** - * Represents a locally stored task list, containing AndroidTasks (whose data objects are Tasks). - * Communicates with third-party content providers to store the tasks. - * Currently, only the OpenTasks tasks provider (org.dmfs.provider.tasks) is supported. + * Represents a locally stored task list, containing [DmfsTask]s (tasks). + * Communicates with tasks.org-compatible content providers (currently tasks.org and OpenTasks) to store the tasks. */ -abstract class AndroidTaskList( +abstract class DmfsTaskList( val account: Account, - val provider: TaskProvider, - val taskFactory: AndroidTaskFactory, + val provider: ContentProviderClient, + val providerName: TaskProvider.ProviderName, + val taskFactory: DmfsTaskFactory, val id: Long ) { companion object { - fun create(account: Account, provider: TaskProvider, info: ContentValues): Uri { + private val logger + get() = Logger.getLogger(DmfsTaskList::class.java.name) + + fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: ContentValues): Uri { info.put(TaskContract.ACCOUNT_NAME, account.name) info.put(TaskContract.ACCOUNT_TYPE, account.type) - Ical4Android.log.log(Level.FINE, "Creating ${provider.name.authority} task list", info) - return provider.client.insert(provider.taskListsUri().asSyncAdapter(account), info) + val url = TaskLists.getContentUri(providerName.authority).asSyncAdapter(account) + logger.log(Level.FINE, "Creating ${providerName.authority} task list", info) + return provider.insert(url, info) ?: throw CalendarStorageException("Couldn't create task list (empty result from provider)") } - fun > findByID( + fun > findByID( account: Account, - provider: TaskProvider, - factory: AndroidTaskListFactory, + provider: ContentProviderClient, + providerName: TaskProvider.ProviderName, + factory: DmfsTaskListFactory, id: Long ): T { - provider.client.query( - ContentUris.withAppendedId(provider.taskListsUri(), id).asSyncAdapter(account), + provider.query( + ContentUris.withAppendedId(TaskLists.getContentUri(providerName.authority), id).asSyncAdapter(account), null, null, null, null )?.use { cursor -> if (cursor.moveToNext()) { - val taskList = factory.newInstance(account, provider, id) + val taskList = factory.newInstance(account, provider, providerName, id) taskList.populate(cursor.toValues()) return taskList } @@ -64,16 +74,17 @@ abstract class AndroidTaskList( throw FileNotFoundException() } - fun > find( + fun > find( account: Account, - provider: TaskProvider, - factory: AndroidTaskListFactory, + factory: DmfsTaskListFactory, + provider: ContentProviderClient, + providerName: TaskProvider.ProviderName, where: String?, whereArgs: Array? ): List { val taskLists = LinkedList() - provider.client.query( - provider.taskListsUri().asSyncAdapter(account), + provider.query( + TaskLists.getContentUri(providerName.authority).asSyncAdapter(account), null, where, whereArgs, @@ -82,7 +93,7 @@ abstract class AndroidTaskList( while (cursor.moveToNext()) { val values = cursor.toValues() val taskList = - factory.newInstance(account, provider, values.getAsLong(TaskLists._ID)) + factory.newInstance(account, provider, providerName, values.getAsLong(TaskLists._ID)) taskList.populate(values) taskLists += taskList } @@ -99,7 +110,17 @@ abstract class AndroidTaskList( var isVisible = false - protected fun populate(values: ContentValues) { + /** + * Sets the task list properties ([syncId], [name] etc.) from the passed argument, + * which is usually directly taken from the tasks provider. + * + * Called when an instance is created from a tasks provider data row, for example + * using [find]. + * + * @param values values from tasks provider + */ + @CallSuper + protected open fun populate(values: ContentValues) { syncId = values.getAsString(TaskLists._SYNC_ID) name = values.getAsString(TaskLists.LIST_NAME) color = values.getAsInteger(TaskLists.LIST_COLOR) @@ -108,13 +129,18 @@ abstract class AndroidTaskList( } fun update(info: ContentValues): Int { - Ical4Android.log.log(Level.FINE, "Updating ${provider.name.authority} task list (#$id)", info) - return provider.client.update(taskListSyncUri(), info, null, null) + logger.log(Level.FINE, "Updating ${providerName.authority} task list (#$id)", info) + return provider.update(taskListSyncUri(), info, null, null) } - fun delete(): Int { - Ical4Android.log.log(Level.FINE, "Deleting ${provider.name.authority} task list (#$id)") - return provider.client.delete(taskListSyncUri(), null, null) + /** + * Deletes this calendar from the local calendar provider. + * + * @return `true` if the calendar was deleted, `false` otherwise (like it was not there before the call) + */ + fun delete(): Boolean { + logger.log(Level.FINE, "Deleting ${providerName.authority} task list (#$id)") + return provider.delete(taskListSyncUri(), null, null) > 0 } /** @@ -138,9 +164,9 @@ abstract class AndroidTaskList( * @return number of touched [Relation] rows */ fun touchRelations(): Int { - Ical4Android.log.fine("Touching relations to set parent_id") - val batchOperation = BatchOperation(provider.client) - provider.client.query( + logger.fine("Touching relations to set parent_id") + val batchOperation = BatchOperation(provider, BatchOperation.TASKS_OPERATIONS_PER_YIELD_POINT) + provider.query( tasksSyncUri(true), null, "${Tasks.LIST_ID}=? AND ${Tasks.PARENT_ID} IS NULL AND ${Relation.MIMETYPE}=? AND ${Relation.RELATED_ID} IS NOT NULL", arrayOf(id.toString(), Relation.CONTENT_ITEM_TYPE), @@ -175,7 +201,7 @@ abstract class AndroidTaskList( val whereArgs = (_whereArgs ?: arrayOf()) + id.toString() val tasks = LinkedList() - provider.client.query( + provider.query( tasksSyncUri(), null, where, whereArgs, null @@ -191,10 +217,10 @@ abstract class AndroidTaskList( fun taskListSyncUri() = - ContentUris.withAppendedId(provider.taskListsUri(), id).asSyncAdapter(account) + ContentUris.withAppendedId(TaskLists.getContentUri(providerName.authority), id).asSyncAdapter(account) fun tasksSyncUri(loadProperties: Boolean = false): Uri { - val uri = provider.tasksUri().asSyncAdapter(account) + val uri = Tasks.getContentUri(providerName.authority).asSyncAdapter(account) return if (loadProperties) uri.buildUpon() .appendQueryParameter(TaskContract.LOAD_PROPERTIES, "1") @@ -203,6 +229,6 @@ abstract class AndroidTaskList( uri } - fun tasksPropertiesSyncUri() = provider.propertiesUri().asSyncAdapter(account) + fun tasksPropertiesSyncUri() = TaskContract.Properties.getContentUri(providerName.authority).asSyncAdapter(account) } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskListFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskListFactory.kt new file mode 100644 index 00000000..12a38a8d --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/DmfsTaskListFactory.kt @@ -0,0 +1,16 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.accounts.Account +import android.content.ContentProviderClient + +interface DmfsTaskListFactory> { + + fun newInstance(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, id: Long): T + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/Event.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt similarity index 67% rename from src/main/java/at/bitfire/ical4android/Event.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/Event.kt index 5724d645..0eaa9ad9 100644 --- a/src/main/java/at/bitfire/ical4android/Event.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -9,73 +11,112 @@ import at.bitfire.ical4android.util.DateUtils.isDateTime import at.bitfire.ical4android.validation.EventValidator import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException -import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.TextList import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.property.* +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStamp +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.ExRule +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Transp +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import net.fortuna.ical4j.model.property.Version import java.io.IOException import java.io.OutputStream import java.io.Reader import java.net.URI -import java.util.* +import java.util.LinkedList +import java.util.UUID +import java.util.logging.Logger -class Event: ICalendar() { +data class Event( + override var uid: String? = null, + override var sequence: Int? = null, + + /** list of Calendar User Agents which have edited the event since last sync */ + override var userAgents: LinkedList = LinkedList(), // uid and sequence are inherited from iCalendar - var recurrenceId: RecurrenceId? = null + var recurrenceId: RecurrenceId? = null, - var summary: String? = null - var location: String? = null - var url: URI? = null - var description: String? = null - var color: Css3Color? = null + var summary: String? = null, + var location: String? = null, + var url: URI? = null, + var description: String? = null, + var color: Css3Color? = null, - var dtStart: DtStart? = null - var dtEnd: DtEnd? = null + var dtStart: DtStart? = null, + var dtEnd: DtEnd? = null, - var duration: Duration? = null - val rRules = LinkedList() - val exRules = LinkedList() - val rDates = LinkedList() - val exDates = LinkedList() + var duration: Duration? = null, + val rRules: LinkedList = LinkedList(), + val exRules: LinkedList = LinkedList(), + val rDates: LinkedList = LinkedList(), + val exDates: LinkedList = LinkedList(), - val exceptions = LinkedList() + val exceptions: LinkedList = LinkedList(), - var classification: Clazz? = null - var status: Status? = null + var classification: Clazz? = null, + var status: Status? = null, - var opaque = true + var opaque: Boolean = true, - var organizer: Organizer? = null - val attendees = LinkedList() + var organizer: Organizer? = null, + val attendees: LinkedList = LinkedList(), - val alarms = LinkedList() + val alarms: LinkedList = LinkedList(), - var lastModified: LastModified? = null + var lastModified: LastModified? = null, - val categories = LinkedList() - val unknownProperties = LinkedList() + val categories: LinkedList = LinkedList(), + val unknownProperties: LinkedList = LinkedList() +) : ICalendar() { companion object { + + private val logger + get() = Logger.getLogger(Event::class.java.name) + /** - * Parses an iCalendar resource, applies [ICalPreprocessor] to increase compatibility - * and extracts the VEVENTs. + * Parses an iCalendar resource, applies [at.bitfire.ical4android.validation.ICalPreprocessor] + * and [EventValidator] to increase compatibility and extracts the VEVENTs. * * @param reader where the iCalendar is taken from * @param properties Known iCalendar properties (like [CALENDAR_NAME]) will be put into this map. Key: property name; value: property value * * @return array of filled [Event] data objects (may have size 0) * - * @throws ParserException when the iCalendar can't be parsed - * @throws IllegalArgumentException when the iCalendar resource contains an invalid value * @throws IOException on I/O errors - * @throws InvalidCalendarException on parsing exceptions + * @throws ParserException when the iCalendar can't be parsed */ - @UsesThreadContextClassLoader - fun eventsFromReader(reader: Reader, properties: MutableMap? = null): List { + fun eventsFromReader( + reader: Reader, + properties: MutableMap? = null + ): List { val ical = fromReader(reader, properties) // process VEVENTs @@ -85,13 +126,13 @@ class Event: ICalendar() { for (vEvent in vEvents) if (vEvent.uid == null) { val uid = Uid(UUID.randomUUID().toString()) - Ical4Android.log.warning("Found VEVENT without UID, using a random one: ${uid.value}") + logger.warning("Found VEVENT without UID, using a random one: ${uid.value}") vEvent.properties += uid } - Ical4Android.log.fine("Assigning exceptions to main events") - val mainEvents = mutableMapOf() - val exceptions = mutableMapOf>() + logger.fine("Assigning exceptions to main events") + val mainEvents = mutableMapOf() + val exceptions = mutableMapOf>() for (vEvent in vEvents) { val uid = vEvent.uid.value val sequence = vEvent.sequence?.sequenceNo ?: 0 @@ -141,7 +182,7 @@ class Event: ICalendar() { } for ((uid, onlyExceptions) in exceptions) { - Ical4Android.log.info("UID $uid doesn't have a main event but only exceptions: $onlyExceptions") + logger.info("UID $uid doesn't have a main event but only exceptions: $onlyExceptions") // create a fake main event from the first exception val fakeEvent = fromVEvent(onlyExceptions.values.first()) @@ -150,6 +191,10 @@ class Event: ICalendar() { events += fakeEvent } + // Try to repair all events after reading the whole iCalendar + for (event in events) + EventValidator.repair(event) + return events } @@ -172,6 +217,7 @@ class Event: ICalendar() { is Categories -> for (category in prop.categories) e.categories += category + is Color -> e.color = Css3Color.fromString(prop.value) is DtStart -> e.dtStart = prop is DtEnd -> e.dtEnd = prop @@ -187,30 +233,25 @@ class Event: ICalendar() { is Attendee -> e.attendees += prop is LastModified -> e.lastModified = prop is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } + else -> e.unknownProperties += prop } e.alarms.addAll(event.alarms) - // validate and repair - EventValidator(e).repair() - return e } } - @UsesThreadContextClassLoader fun write(os: OutputStream) { - Ical4Android.checkThreadContextClassLoader() - val ical = Calendar() ical.properties += Version.VERSION_2_0 ical.properties += prodId() val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") - EventValidator(this).repair() // validate and repair this event before creating VEVENT + EventValidator.repair(this) // repair this event before creating the VEVENT // "main event" (without exceptions) val components = ical.components @@ -229,7 +270,7 @@ class Event: ICalendar() { val recurrenceId = exception.recurrenceId if (recurrenceId == null) { - Ical4Android.log.warning("Ignoring exception without recurrenceId") + logger.warning("Ignoring exception without recurrenceId") continue } @@ -238,13 +279,13 @@ class Event: ICalendar() { strict in what we send (and servers may reject such a case). */ if (isDateTime(recurrenceId) != isDateTime(dtStart)) { - Ical4Android.log.warning("Ignoring exception $recurrenceId with other date type than dtStart: $dtStart") + logger.warning("Ignoring exception $recurrenceId with other date type than dtStart: $dtStart") continue } // for simplicity and compatibility, rewrite date-time exceptions to the same time zone as DTSTART if (isDateTime(recurrenceId) && recurrenceId.timeZone != dtStart.timeZone) { - Ical4Android.log.fine("Changing timezone of $recurrenceId to same time zone as dtStart: $dtStart") + logger.fine("Changing timezone of $recurrenceId to same time zone as dtStart: $dtStart") recurrenceId.timeZone = dtStart.timeZone } @@ -314,23 +355,23 @@ class Event: ICalendar() { lastModified?.let { props += it } - event.alarms.addAll(alarms) + event.components.addAll(alarms) return event } val organizerEmail: String? - get() { - var email: String? = null - organizer?.let { organizer -> - val uri = organizer.calAddress - email = if (uri.scheme.equals("mailto", true)) - uri.schemeSpecificPart - else - organizer.getParameter(Parameter.EMAIL)?.value + get() { + var email: String? = null + organizer?.let { organizer -> + val uri = organizer.calAddress + email = if (uri.scheme.equals("mailto", true)) + uri.schemeSpecificPart + else + organizer.getParameter(Parameter.EMAIL)?.value + } + return email } - return email - } } diff --git a/src/main/java/at/bitfire/ical4android/ICalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt similarity index 80% rename from src/main/java/at/bitfire/ical4android/ICalendar.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt index 7cbc3fe3..20523d31 100644 --- a/src/main/java/at/bitfire/ical4android/ICalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt @@ -1,51 +1,58 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android -import at.bitfire.ical4android.util.MiscUtils +import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME import at.bitfire.ical4android.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder +import net.fortuna.ical4j.data.CalendarParserFactory +import net.fortuna.ical4j.data.ContentHandlerContext import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.component.* +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.Daylight +import net.fortuna.ical4j.model.component.Observance +import net.fortuna.ical4j.model.component.Standard +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.component.VTimeZone +import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.property.* +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.validate.ValidationException import java.io.Reader import java.io.StringReader import java.time.Duration import java.time.Period -import java.util.* +import java.util.LinkedList +import java.util.UUID import java.util.logging.Level import java.util.logging.Logger open class ICalendar { - var uid: String? = null - var sequence: Int? = null + open var uid: String? = null + open var sequence: Int? = null /** list of CUAs which have edited the event since last sync */ - var userAgents = LinkedList() + open var userAgents = LinkedList() companion object { - // static ical4j initialization - init { - // reduce verbosity of various ical4j loggers - org.slf4j.LoggerFactory.getLogger(net.fortuna.ical4j.data.CalendarParserImpl::class.java) - Logger.getLogger(net.fortuna.ical4j.data.CalendarParserImpl::class.java.name).level = Level.CONFIG - - org.slf4j.LoggerFactory.getLogger(net.fortuna.ical4j.model.Recur::class.java) - Logger.getLogger(net.fortuna.ical4j.model.Recur::class.java.name).level = Level.CONFIG - - org.slf4j.LoggerFactory.getLogger(net.fortuna.ical4j.data.FoldingWriter::class.java) - Logger.getLogger(net.fortuna.ical4j.data.FoldingWriter::class.java.name).level = Level.CONFIG - } + private val logger + get() = Logger.getLogger(ICalendar::class.java.name) // known iCalendar properties const val CALENDAR_NAME = "X-WR-CALNAME" @@ -75,10 +82,8 @@ open class ICalendar { * @throws ParserException when the iCalendar can't be parsed * @throws IllegalArgumentException when the iCalendar resource contains an invalid value */ - @UsesThreadContextClassLoader fun fromReader(reader: Reader, properties: MutableMap? = null): Calendar { - Ical4Android.log.fine("Parsing iCalendar stream") - Ical4Android.checkThreadContextClassLoader() + logger.fine("Parsing iCalendar stream") // preprocess stream to work around some problems that can't be fixed later val preprocessed = ICalPreprocessor.preprocessStream(reader) @@ -86,7 +91,11 @@ open class ICalendar { // parse stream val calendar: Calendar try { - calendar = CalendarBuilder().build(preprocessed) + calendar = CalendarBuilder( + CalendarParserFactory.getInstance().get(), + ContentHandlerContext().withSupressInvalidProperties(true), + TimeZoneRegistryFactory.getInstance().createRegistry() // AndroidCompatTimeZoneRegistry + ).build(preprocessed) } catch(e: ParserException) { throw InvalidCalendarException("Couldn't parse iCalendar", e) } catch(e: IllegalArgumentException) { @@ -97,7 +106,7 @@ open class ICalendar { try { ICalPreprocessor.preprocessCalendar(calendar) } catch (e: Exception) { - Ical4Android.log.log(Level.WARNING, "Couldn't pre-process iCalendar", e) + logger.log(Level.WARNING, "Couldn't pre-process iCalendar", e) } // fill calendar properties @@ -134,14 +143,14 @@ open class ICalendar { * @return minified time zone definition */ fun minifyVTimeZone(originalTz: VTimeZone, start: Date?): VTimeZone { - val newTz = originalTz.copy() as VTimeZone + var newTz: VTimeZone? = null val keep = mutableSetOf() if (start != null) { // find latest matching STANDARD/DAYLIGHT observances var latestDaylight: Pair? = null var latestStandard: Pair? = null - for (observance in newTz.observances) { + for (observance in originalTz.observances) { val latest = observance.getLatestOnset(start) if (latest == null) // observance begins after "start", keep in any case @@ -193,28 +202,27 @@ open class ICalendar { } } - // remove all observances that shall not be kept - val iterator = newTz.observances.iterator() as MutableIterator - while (iterator.hasNext()) { - val entry = iterator.next() - if (!keep.contains(entry)) - iterator.remove() + // construct minified time zone that only contains the ID and relevant observances + val relevantProperties = PropertyList().apply { + add(originalTz.timeZoneId) + } + val relevantObservances = ComponentList().apply { + addAll(keep) + } + newTz = VTimeZone(relevantProperties, relevantObservances) + + // validate minified timezone + try { + newTz.validate() + } catch (e: ValidationException) { + // This should never happen! + logger.log(Level.WARNING, "Minified timezone is invalid, using original one", e) + newTz = null } } - // remove unnecessary properties - newTz.properties.removeAll { it is TzUrl || it is XProperty } - - // validate minified timezone - try { - newTz.validate() - } catch (e: ValidationException) { - // This should never happen! - Ical4Android.log.log(Level.WARNING, "Minified timezone is invalid, using original one", e) - return originalTz - } - - return newTz + // use original time zone if we couldn't calculate a minified one + return newTz ?: originalTz } /** @@ -222,16 +230,14 @@ open class ICalendar { * @param timezoneDef time zone definition (VCALENDAR with VTIMEZONE component) * @return time zone id (TZID) if VTIMEZONE contains a TZID, null otherwise */ - @UsesThreadContextClassLoader fun timezoneDefToTzId(timezoneDef: String): String? { - Ical4Android.checkThreadContextClassLoader() try { val builder = CalendarBuilder() val cal = builder.build(StringReader(timezoneDef)) val timezone = cal.getComponent(VTimeZone.VTIMEZONE) as VTimeZone? timezone?.timeZoneId?.let { return it.value } } catch (e: ParserException) { - Ical4Android.log.log(Level.SEVERE, "Can't understand time zone definition", e) + logger.log(Level.SEVERE, "Can't understand time zone definition", e) } return null } @@ -254,7 +260,7 @@ open class ICalendar { // debug build, re-throw ValidationException throw e else - Ical4Android.log.log(Level.WARNING, "iCalendar validation failed - This is only a warning!", e) + logger.log(Level.WARNING, "iCalendar validation failed - This is only a warning!", e) } } @@ -337,7 +343,7 @@ open class ICalendar { if (related == Related.END && !allowRelEnd) { if (duration == null) { - Ical4Android.log.warning("Event/task without duration; can't calculate END-related alarm") + logger.warning("Event/task without duration; can't calculate END-related alarm") return null } // move alarm towards end @@ -352,7 +358,7 @@ open class ICalendar { minutes = Duration.between(triggerTime.toInstant(), start.toInstant()).toMinutes().toInt() } else { - Ical4Android.log.log(Level.WARNING, "VALARM TRIGGER type is not DURATION or DATE-TIME (requires event DTSTART for Android), ignoring alarm", alarm) + logger.log(Level.WARNING, "VALARM TRIGGER type is not DURATION or DATE-TIME (requires event DTSTART for Android), ignoring alarm", alarm) return null } @@ -368,6 +374,4 @@ open class ICalendar { fun prodId(): ProdId = prodId(userAgents) - override fun toString() = MiscUtils.reflectionToString(this) - } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Ical4jVersion.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Ical4jVersion.kt new file mode 100644 index 00000000..38f5a200 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Ical4jVersion.kt @@ -0,0 +1,13 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +/** + * The used version of ical4j. + */ +@Suppress("unused") +const val ical4jVersion = BuildConfig.version_ical4j diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/InvalidCalendarException.kt b/lib/src/main/kotlin/at/bitfire/ical4android/InvalidCalendarException.kt new file mode 100644 index 00000000..af87d994 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/InvalidCalendarException.kt @@ -0,0 +1,14 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +class InvalidCalendarException: Exception { + + constructor(message: String): super(message) + constructor(message: String, ex: Throwable): super(message, ex) + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt new file mode 100644 index 00000000..53bc18b4 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt @@ -0,0 +1,282 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import at.bitfire.ical4android.util.MiscUtils.toValues +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.asSyncAdapter +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.component.VJournal +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.property.Version +import java.util.LinkedList +import java.util.logging.Level +import java.util.logging.Logger + +open class JtxCollection(val account: Account, + val client: ContentProviderClient, + private val iCalObjectFactory: JtxICalObjectFactory, + val id: Long) { + + companion object { + + private val logger + get() = Logger.getLogger(JtxCollection::class.java.name) + + fun create(account: Account, client: ContentProviderClient, values: ContentValues): Uri { + logger.log(Level.FINE, "Creating jtx Board collection", values) + return client.insert(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), values) + ?: throw CalendarStorageException("Couldn't create JTX Collection") + } + + fun> find(account: Account, client: ContentProviderClient, context: Context, factory: JtxCollectionFactory, where: String?, whereArgs: Array?): List { + val collections = LinkedList() + client.query(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null)?.use { cursor -> + while (cursor.moveToNext()) { + val values = cursor.toValues() + val collection = factory.newInstance(account, client, values.getAsLong(JtxContract.JtxCollection.ID)) + collection.populate(values, context) + collections += collection + } + } + return collections + } + } + + + var url: String? = null + var displayname: String? = null + var syncstate: String? = null + + var supportsVEVENT = true + var supportsVTODO = true + var supportsVJOURNAL = true + + var syncId: Long? = null + + var context: Context? = null + + + fun delete(): Boolean { + logger.log(Level.FINE, "Deleting jtx Board collection (#$id)") + return client.delete(ContentUris.withAppendedId(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), id), null, null) > 0 + } + + fun update(values: ContentValues) { + logger.log(Level.FINE, "Updating jtx Board collection (#$id)", values) + client.update(ContentUris.withAppendedId(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), id), values, null, null) + } + + protected fun populate(values: ContentValues, context: Context) { + url = values.getAsString(JtxContract.JtxCollection.URL) + displayname = values.getAsString(JtxContract.JtxCollection.DISPLAYNAME) + syncstate = values.getAsString(JtxContract.JtxCollection.SYNC_VERSION) + + supportsVEVENT = values.getAsString(JtxContract.JtxCollection.SUPPORTSVEVENT) == "1" + || values.getAsString(JtxContract.JtxCollection.SUPPORTSVEVENT) == "true" + supportsVTODO = values.getAsString(JtxContract.JtxCollection.SUPPORTSVTODO) == "1" + || values.getAsString(JtxContract.JtxCollection.SUPPORTSVTODO) == "true" + supportsVJOURNAL = values.getAsString(JtxContract.JtxCollection.SUPPORTSVJOURNAL) == "1" + || values.getAsString(JtxContract.JtxCollection.SUPPORTSVJOURNAL) == "true" + + syncId = values.getAsLong(JtxContract.JtxCollection.SYNC_ID) + + this.context = context + } + + + /** + * Builds the JtxICalObject content uri with appended parameters for account and syncadapter + * @return the Uri for the JtxICalObject Sync in the content provider of jtx Board + */ + fun jtxSyncURI(): Uri = + JtxContract.JtxICalObject.CONTENT_URI.buildUpon() + .appendQueryParameter(JtxContract.ACCOUNT_NAME, account.name) + .appendQueryParameter(JtxContract.ACCOUNT_TYPE, account.type) + .appendQueryParameter(JtxContract.CALLER_IS_SYNCADAPTER, "true") + .build() + + + /** + * @return a list of content values of the deleted jtxICalObjects + */ + fun queryDeletedICalObjects(): List { + val values = mutableListOf() + client.query( + JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), + null, + "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", arrayOf(id.toString(), "1"), + null + ).use { cursor -> + logger.fine("findDeleted: found ${cursor?.count} deleted records in ${account.name}") + while (cursor?.moveToNext() == true) { + values.add(cursor.toValues()) + } + } + return values + } + + + /** + * @return a list of content values of the dirty jtxICalObjects + */ + fun queryDirtyICalObjects(): List { + val values = mutableListOf() + client.query( + JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), + null, + "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", arrayOf(id.toString(), "1"), + null + ).use { cursor -> + logger.fine("findDirty: found ${cursor?.count} dirty records in ${account.name}") + while (cursor?.moveToNext() == true) { + values.add(cursor.toValues()) + } + } + return values + } + + /** + * @param [filename] of the entry that should be retrieved as content values + * @return Content Values of the found item with the given filename or null if the result was empty or more than 1 + */ + fun queryByFilename(filename: String): ContentValues? { + client.query( + JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), + null, + "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.FILENAME} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", arrayOf(id.toString(), filename), + null + ).use { cursor -> + logger.fine("queryByFilename: found ${cursor?.count} records in ${account.name}") + if (cursor?.count != 1) + return null + cursor.moveToFirst() + return cursor.toValues() + } + } + + /** + * @param [uid] of the entry that should be retrieved as content values + * @return Content Values of the found item with the given UID or null if the result was empty or more than 1 + * The query checks for the [uid] within all collections of this account, not only the current collection. + */ + fun queryByUID(uid: String): ContentValues? { + client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.UID} = ?", arrayOf(uid), null).use { cursor -> + logger.fine("queryByUID: found ${cursor?.count} records in ${account.name}") + if (cursor?.count != 1) + return null + cursor.moveToFirst() + return cursor.toValues() + } + } + + + /** + * @param [uid] of the entry that should be retrieved as content values + * @param [recurid] of the entry that should be retrieved as content values + * @return Content Values of the found item with the given UID or null if the result was empty or more than 1 + * The query checks for the [uid] within all collections of this account, not only the current collection. + */ + fun queryRecur(uid: String, recurid: String): ContentValues? { + client.query( + JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), + null, + "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} = ?", + arrayOf(uid, recurid), + null + ).use { cursor -> + logger.fine("queryRecur: found ${cursor?.count} records in ${account.name}") + if (cursor?.count != 1) + return null + cursor.moveToFirst() + return cursor.toValues() + } + } + + /** + * updates the flags of all entries in the collection with the given flag + * @param [flags] to be set + * @return the number of records that were updated + */ + fun updateSetFlags(flags: Int): Int { + val values = ContentValues(1) + values.put(JtxContract.JtxICalObject.FLAGS, flags) + return client.update( + JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), + values, + "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ?", + arrayOf(id.toString(), "0") + ) + } + + /** + * deletes all entries with the given flags + * @param [flags] of the entries that should be deleted + * @return the number of deleted records + */ + fun deleteByFlags(flags: Int) = + client.delete(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), "${JtxContract.JtxICalObject.DIRTY} = ? AND ${JtxContract.JtxICalObject.FLAGS} = ? ", arrayOf("0", flags.toString())) + + /** + * Updates the eTag value of all entries within a collection to the given eTag + * @param [eTag] to be set (or null) + */ + fun updateSetETag(eTag: String?) { + val values = ContentValues(1) + if(eTag == null) + values.putNull(JtxContract.JtxICalObject.ETAG) + else + values.put(JtxContract.JtxICalObject.ETAG, eTag) + client.update(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), values, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ?", arrayOf(id.toString())) + } + + + /** + * @return a string with all JtxICalObjects within the collection as iCalendar + */ + fun getICSForCollection(): String { + client.query( + JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), + null, + "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", + arrayOf(id.toString(), "0"), + null + ).use { cursor -> + logger.fine("getICSForCollection: found ${cursor?.count} records in ${account.name}") + + val ical = Calendar() + ical.properties += Version.VERSION_2_0 + ical.properties += ICalendar.prodId + + while (cursor?.moveToNext() == true) { + val jtxIcalObject = JtxICalObject(this) + jtxIcalObject.populateFromContentValues(cursor.toValues()) + val singleICS = jtxIcalObject.getICalendarFormat() + singleICS?.components?.forEach { component -> + if(component is VToDo || component is VJournal) + ical.components += component + } + } + return ical.toString() + } + } + + /** + * Updates the last sync datetime for all collections of an account + */ + fun updateLastSync() { + val values = ContentValues(1) + values.put(JtxContract.JtxCollection.LAST_SYNC, System.currentTimeMillis()) + client.update(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), values, "${JtxContract.JtxCollection.ID} = ?", arrayOf(id.toString())) + } +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollectionFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollectionFactory.kt new file mode 100644 index 00000000..cf470236 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollectionFactory.kt @@ -0,0 +1,16 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.accounts.Account +import android.content.ContentProviderClient + +interface JtxCollectionFactory> { + + fun newInstance(account: Account, client: ContentProviderClient, id: Long): T + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt similarity index 59% rename from src/main/java/at/bitfire/ical4android/JtxICalObject.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 6b19aec2..ecf5b1e5 100644 --- a/src/main/java/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -10,27 +12,90 @@ import android.net.ParseException import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Base64 -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues +import at.bitfire.ical4android.util.MiscUtils.toValues import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY import at.techbee.jtx.JtxContract.asSyncAdapter import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException -import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.TextList +import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VJournal import net.fortuna.ical4j.model.component.VToDo -import net.fortuna.ical4j.model.parameter.* -import net.fortuna.ical4j.model.property.* -import java.io.* +import net.fortuna.ical4j.model.parameter.AltRep +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.DelegatedFrom +import net.fortuna.ical4j.model.parameter.DelegatedTo +import net.fortuna.ical4j.model.parameter.Dir +import net.fortuna.ical4j.model.parameter.FmtType +import net.fortuna.ical4j.model.parameter.Language +import net.fortuna.ical4j.model.parameter.Member +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.SentBy +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attach +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Comment +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Contact +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStamp +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Repeat +import net.fortuna.ical4j.model.property.Resources +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Trigger +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import net.fortuna.ical4j.model.property.Version +import net.fortuna.ical4j.model.property.XProperty +import java.io.FileNotFoundException +import java.io.IOException +import java.io.OutputStream +import java.io.Reader import java.net.URI import java.net.URISyntaxException import java.time.format.DateTimeParseException -import java.util.* import java.util.TimeZone +import java.util.UUID import java.util.logging.Level +import java.util.logging.Logger open class JtxICalObject( val collection: JtxCollection @@ -47,6 +112,7 @@ open class JtxICalObject( var classification: String? = null var status: String? = null + var xstatus: String? = null var priority: Int? = null @@ -63,6 +129,7 @@ open class JtxICalObject( var geoLong: Double? = null var location: String? = null var locationAltrep: String? = null + var geofenceRadius: Int? = null var uid: String = UUID.randomUUID().toString() @@ -77,8 +144,8 @@ open class JtxICalObject( var exdate: String? = null //only for recurring events, see https://tools.ietf.org/html/rfc5545#section-3.8.5.1 var rdate: String? = null //only for recurring events, see https://tools.ietf.org/html/rfc5545#section-3.8.5.2 var recurid: String? = null //only for recurring events, see https://tools.ietf.org/html/rfc5545#section-3.8.5 - - var rstatus: String? = null + var recuridTimezone: String? = null + //var rstatus: String? = null var collectionId: Long = collection.id @@ -100,6 +167,8 @@ open class JtxICalObject( var alarms: MutableList = mutableListOf() var unknown: MutableList = mutableListOf() + private var recurInstances: MutableList = mutableListOf() + @@ -195,8 +264,14 @@ open class JtxICalObject( companion object { + private val logger + get() = Logger.getLogger(JtxICalObject::class.java.name) + const val X_PROP_COMPLETEDTIMEZONE = "X-COMPLETEDTIMEZONE" - const val X_PARAM_ATTACH_LABEL = "X-LABEL" // used for filename + const val X_PARAM_ATTACH_LABEL = "X-LABEL" // used for filename in KOrganizer + const val X_PARAM_FILENAME = "FILENAME" // used for filename in GNOME Evolution + const val X_PROP_XSTATUS = "X-STATUS" // used to define an extended status (additionally to standard status) + const val X_PROP_GEOFENCE_RADIUS = "X-GEOFENCE-RADIUS" // used to define a Geofence-Radius to notifiy the user when close /** * Parses an iCalendar resource and extracts the VTODOs and/or VJOURNALS. @@ -209,7 +284,6 @@ open class JtxICalObject( * @throws IllegalArgumentException when the iCalendar resource contains an invalid value * @throws IOException on I/O errors */ - @UsesThreadContextClassLoader fun fromReader( reader: Reader, collection: JtxCollection @@ -317,15 +391,16 @@ open class JtxICalObject( is Description -> iCalObject.description = prop.value is Color -> iCalObject.color = Css3Color.fromString(prop.value)?.argb is Url -> iCalObject.url = prop.value + is Contact -> iCalObject.contact = prop.value is Priority -> iCalObject.priority = prop.level is Clazz -> iCalObject.classification = prop.value is Status -> iCalObject.status = prop.value - is DtEnd -> Ical4Android.log.warning("The property DtEnd must not be used for VTODO and VJOURNAL, this value is rejected.") + is DtEnd -> logger.warning("The property DtEnd must not be used for VTODO and VJOURNAL, this value is rejected.") is Completed -> { if (iCalObject.component == JtxContract.JtxICalObject.Component.VTODO.name) { iCalObject.completed = prop.date.time } else - Ical4Android.log.warning("The property Completed is only supported for VTODO, this value is rejected.") + logger.warning("The property Completed is only supported for VTODO, this value is rejected.") } is Due -> { @@ -338,7 +413,7 @@ open class JtxICalObject( else -> iCalObject.dueTimezone = TZ_ALLDAY // prop.date is Date (and not DateTime), therefore it must be Allday } } else - Ical4Android.log.warning("The property Due is only supported for VTODO, this value is rejected.") + logger.warning("The property Due is only supported for VTODO, this value is rejected.") } is Duration -> iCalObject.duration = prop.value @@ -357,7 +432,7 @@ open class JtxICalObject( if (iCalObject.component == JtxContract.JtxICalObject.Component.VTODO.name) iCalObject.percent = prop.percentage else - Ical4Android.log.warning("The property PercentComplete is only supported for VTODO, this value is rejected.") + logger.warning("The property PercentComplete is only supported for VTODO, this value is rejected.") } is RRule -> iCalObject.rrule = prop.value @@ -381,7 +456,15 @@ open class JtxICalObject( } iCalObject.exdate = exdateList.toTypedArray().joinToString(separator = ",") } - is RecurrenceId -> iCalObject.recurid = prop.value + is RecurrenceId -> { + iCalObject.recurid = prop.date.toString() + iCalObject.recuridTimezone = when { + prop.date is DateTime && prop.timeZone != null -> prop.timeZone.id + prop.date is DateTime && prop.isUtc -> TimeZone.getTimeZone("UTC").id + prop.date is DateTime && !prop.isUtc && prop.timeZone == null -> null + else -> TZ_ALLDAY // prop.date is Date (and not DateTime), therefore it must be Allday + } + } //is RequestStatus -> iCalObject.rstatus = prop.value @@ -424,6 +507,10 @@ open class JtxICalObject( attachment.filename = it.value prop.parameters.remove(it) } + prop.parameters?.getParameter(X_PARAM_FILENAME)?.let { + attachment.filename = it.value + prop.parameters.remove(it) + } attachment.other = JtxContract.getJsonStringFromXParameters(prop.parameters) @@ -496,18 +583,18 @@ open class JtxICalObject( // save unknown parameters in the other field this.other = JtxContract.getJsonStringFromXParameters(prop.parameters) + } } - } is Uid -> iCalObject.uid = prop.value //is Uid, is ProdId, is DtStamp -> { } /* don't save these as unknown properties */ - else -> { - if(prop.name == X_PROP_COMPLETEDTIMEZONE) - iCalObject.completedTimezone = prop.value - else - iCalObject.unknown.add(Unknown(value = UnknownProperty.toJsonString(prop))) // save the whole property for unknown properties + else -> when(prop.name) { + X_PROP_COMPLETEDTIMEZONE -> iCalObject.completedTimezone = prop.value + X_PROP_XSTATUS -> iCalObject.xstatus = prop.value + X_PROP_GEOFENCE_RADIUS -> iCalObject.geofenceRadius = try { prop.value.toInt() } catch (e: NumberFormatException) { logger.warning("Wrong format for geofenceRadius: ${prop.value}"); null } + else -> iCalObject.unknown.add(Unknown(value = UnknownProperty.toJsonString(prop))) // save the whole property for unknown properties } } } @@ -519,20 +606,20 @@ open class JtxICalObject( if (dtStartTZ != null && dueTZ != null) { if (dtStartTZ == TZ_ALLDAY && dueTZ != TZ_ALLDAY) { - Ical4Android.log.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") + logger.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") iCalObject.dtstartTimezone = dueTZ } else if (dtStartTZ != TZ_ALLDAY && dueTZ == TZ_ALLDAY) { - Ical4Android.log.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") + logger.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") iCalObject.dueTimezone = dtStartTZ } //previously due was dropped, now reduced to a warning, see also https://github.com/bitfireAT/ical4android/issues/70 if ( iCalObject.dtstart != null && iCalObject.due != null && iCalObject.due!! < iCalObject.dtstart!!) - Ical4Android.log.warning("Found invalid DUE < DTSTART") + logger.warning("Found invalid DUE < DTSTART") } if (iCalObject.duration != null && iCalObject.dtstart == null) { - Ical4Android.log.warning("Found DURATION without DTSTART; ignoring") + logger.warning("Found DURATION without DTSTART; ignoring") iCalObject.duration = null } } @@ -542,10 +629,7 @@ open class JtxICalObject( * Takes the current JtxICalObject and transforms it to a Calendar (ical4j) * @return The current JtxICalObject transformed into a ical4j Calendar */ - @UsesThreadContextClassLoader fun getICalendarFormat(): Calendar? { - Ical4Android.checkThreadContextClassLoader() - val ical = Calendar() ical.properties += Version.VERSION_2_0 ical.properties += ICalendar.prodId(listOf(TaskProvider.ProviderName.JtxBoard.packageName)) @@ -584,7 +668,7 @@ open class JtxICalObject( this.parameters.add(Related.END) } } catch (e: DateTimeParseException) { - Ical4Android.log.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e) + logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e) } }) @@ -607,7 +691,7 @@ open class JtxICalObject( } } } catch (e: ParseException) { - Ical4Android.log.log(Level.WARNING, "TriggerTime could not be parsed.", e) + logger.log(Level.WARNING, "TriggerTime could not be parsed.", e) }}) } alarm.summary?.let { add(Summary(it)) } @@ -617,7 +701,7 @@ open class JtxICalObject( val dur = java.time.Duration.parse(it) this.duration = dur } catch (e: DateTimeParseException) { - Ical4Android.log.log(Level.WARNING, "Could not parse duration as Duration.", e) + logger.log(Level.WARNING, "Could not parse duration as Duration.", e) } }) } alarm.description?.let { add(Description(it)) } @@ -628,6 +712,17 @@ open class JtxICalObject( calComponent.components.add(vAlarm) } + + recurInstances.forEach { recurInstance -> + val recurCalComponent = when (recurInstance.component) { + JtxContract.JtxICalObject.Component.VTODO.name -> VToDo(true /* generates DTSTAMP */) + JtxContract.JtxICalObject.Component.VJOURNAL.name -> VJournal(true /* generates DTSTAMP */) + else -> return null + } + ical.components += recurCalComponent + recurInstance.addProperties(recurCalComponent.properties) + } + ICalendar.softValidate(ical) return ical } @@ -636,9 +731,7 @@ open class JtxICalObject( * Takes the current JtxICalObject, transforms it to an iCalendar and writes it in an OutputStream * @param [os] OutputStream where iCalendar should be written to */ - @UsesThreadContextClassLoader fun write(os: OutputStream) { - Ical4Android.checkThreadContextClassLoader() CalendarOutputter(false).output(this.getICalendarFormat(), os) } @@ -671,18 +764,24 @@ open class JtxICalObject( if (geoLat != null && geoLong != null) { props += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) } + geofenceRadius?.let { geofenceRadius -> + props += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) + } color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } url?.let { try { props += Url(URI(it)) } catch (e: URISyntaxException) { - Ical4Android.log.log(Level.WARNING, "Ignoring invalid task URL: $url", e) + logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) } } + contact?.let { props += Contact(it) } classification?.let { props += Clazz(it) } status?.let { props += Status(it) } - + xstatus?.let { xstatus -> + props += XProperty(X_PROP_XSTATUS, xstatus) + } val categoryTextList = TextList() categories.forEach { @@ -806,7 +905,10 @@ open class JtxICalObject( val attachmentBytes = ParcelFileDescriptor.AutoCloseInputStream(attachmentFile).readBytes() val att = Attach(attachmentBytes).apply { attachment.fmttype?.let { this.parameters.add(FmtType(it)) } - attachment.filename?.let { this.parameters.add(XParameter(X_PARAM_ATTACH_LABEL, it)) } + attachment.filename?.let { + this.parameters.add(XParameter(X_PARAM_ATTACH_LABEL, it)) + this.parameters.add(XParameter(X_PARAM_FILENAME, it)) + } } props += att @@ -814,17 +916,20 @@ open class JtxICalObject( attachment.uri?.let { uri -> val att = Attach(URI(uri)).apply { attachment.fmttype?.let { this.parameters.add(FmtType(it)) } - attachment.filename?.let { this.parameters.add(XParameter(X_PARAM_ATTACH_LABEL, it)) } + attachment.filename?.let { + this.parameters.add(XParameter(X_PARAM_ATTACH_LABEL, it)) + this.parameters.add(XParameter(X_PARAM_FILENAME, it)) + } } props += att } } } catch (e: FileNotFoundException) { - Ical4Android.log.log(Level.WARNING, "File not found at the given Uri: ${attachment.uri}", e) + logger.log(Level.WARNING, "File not found at the given Uri: ${attachment.uri}", e) } catch (e: NullPointerException) { - Ical4Android.log.log(Level.WARNING, "Provided Uri was empty: ${attachment.uri}", e) + logger.log(Level.WARNING, "Provided Uri was empty: ${attachment.uri}", e) } catch (e: IllegalArgumentException) { - Ical4Android.log.log(Level.WARNING, "Uri could not be parsed: ${attachment.uri}", e) + logger.log(Level.WARNING, "Uri could not be parsed: ${attachment.uri}", e) } } @@ -870,7 +975,12 @@ open class JtxICalObject( props += RRule(rrule) } recurid?.let { recurid -> - props += RecurrenceId(recurid) + props += when { + recuridTimezone == TZ_ALLDAY -> RecurrenceId(Date(recurid)) + recuridTimezone == TimeZone.getTimeZone("UTC").id -> RecurrenceId(DateTime(recurid).apply { this.isUtc = true }) + recuridTimezone.isNullOrEmpty() -> RecurrenceId(DateTime(recurid).apply { this.isUtc = false }) + else -> RecurrenceId(DateTime(recurid, TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone(recuridTimezone))) + } } rdate?.let { rdateString -> @@ -1117,156 +1227,147 @@ duration?.let(props::add) // delete the categories, attendees, ... and insert them again after. Only relevant for Update, for an insert there will be no entries if (isUpdate) { - collection.client.delete( - JtxContract.JtxCategory.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxCategory.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + val deleteBatch = BatchOperation(collection.client) + + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxCategory.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxCategory.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxComment.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxComment.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxComment.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxComment.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxResource.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxResource.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxResource.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxResource.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxAttendee.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxAttendee.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxAttendee.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxAttendee.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxOrganizer.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxOrganizer.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxOrganizer.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxOrganizer.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxAttachment.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxAttachment.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxAlarm.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxAlarm.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxAlarm.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxAlarm.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) - collection.client.delete( - JtxContract.JtxUnknown.CONTENT_URI.asSyncAdapter(collection.account), - "${JtxContract.JtxUnknown.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()) + deleteBatch.enqueue( + BatchOperation.CpoBuilder + .newDelete(JtxContract.JtxUnknown.CONTENT_URI.asSyncAdapter(collection.account)) + .withSelection("${JtxContract.JtxUnknown.ICALOBJECT_ID} = ?", arrayOf(this.id.toString())) ) + + deleteBatch.commit() } + val insertBatch = BatchOperation(collection.client) + this.categories.forEach { category -> - val categoryContentValues = ContentValues().apply { - put(JtxContract.JtxCategory.ICALOBJECT_ID, id) - put(JtxContract.JtxCategory.TEXT, category.text) - put(JtxContract.JtxCategory.ID, category.categoryId) - put(JtxContract.JtxCategory.LANGUAGE, category.language) - put(JtxContract.JtxCategory.OTHER, category.other) - } - collection.client.insert( - JtxContract.JtxCategory.CONTENT_URI.asSyncAdapter(collection.account), - categoryContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxCategory.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxCategory.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxCategory.TEXT, category.text) + .withValue(JtxContract.JtxCategory.ID, category.categoryId) + .withValue(JtxContract.JtxCategory.LANGUAGE, category.language) + .withValue(JtxContract.JtxCategory.OTHER, category.other) ) } this.comments.forEach { comment -> - val commentContentValues = ContentValues().apply { - put(JtxContract.JtxComment.ICALOBJECT_ID, id) - put(JtxContract.JtxComment.ID, comment.commentId) - put(JtxContract.JtxComment.TEXT, comment.text) - put(JtxContract.JtxComment.LANGUAGE, comment.language) - put(JtxContract.JtxComment.OTHER, comment.other) - } - collection.client.insert( - JtxContract.JtxComment.CONTENT_URI.asSyncAdapter(collection.account), - commentContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxComment.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxComment.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxComment.ID, comment.commentId) + .withValue(JtxContract.JtxComment.TEXT, comment.text) + .withValue(JtxContract.JtxComment.LANGUAGE, comment.language) + .withValue(JtxContract.JtxComment.OTHER, comment.other) ) } this.resources.forEach { resource -> - val resourceContentValues = ContentValues().apply { - put(JtxContract.JtxResource.ICALOBJECT_ID, id) - put(JtxContract.JtxResource.ID, resource.resourceId) - put(JtxContract.JtxResource.TEXT, resource.text) - put(JtxContract.JtxResource.LANGUAGE, resource.language) - put(JtxContract.JtxResource.OTHER, resource.other) - } - collection.client.insert( - JtxContract.JtxResource.CONTENT_URI.asSyncAdapter(collection.account), - resourceContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxResource.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxResource.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxResource.ID, resource.resourceId) + .withValue(JtxContract.JtxResource.TEXT, resource.text) + .withValue(JtxContract.JtxResource.LANGUAGE, resource.language) + .withValue(JtxContract.JtxResource.OTHER, resource.other) ) } this.relatedTo.forEach { related -> - val relatedToContentValues = ContentValues().apply { - put(JtxContract.JtxRelatedto.ICALOBJECT_ID, id) - put(JtxContract.JtxRelatedto.TEXT, related.text) - put(JtxContract.JtxRelatedto.RELTYPE, related.reltype) - put(JtxContract.JtxRelatedto.OTHER, related.other) - } - collection.client.insert( - JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(collection.account), - relatedToContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxRelatedto.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxRelatedto.TEXT, related.text) + .withValue(JtxContract.JtxRelatedto.RELTYPE, related.reltype) + .withValue(JtxContract.JtxRelatedto.OTHER, related.other) ) } this.attendees.forEach { attendee -> - val attendeeContentValues = ContentValues().apply { - put(JtxContract.JtxAttendee.ICALOBJECT_ID, id) - put(JtxContract.JtxAttendee.CALADDRESS, attendee.caladdress) - put(JtxContract.JtxAttendee.CN, attendee.cn) - put(JtxContract.JtxAttendee.CUTYPE, attendee.cutype) - put(JtxContract.JtxAttendee.DELEGATEDFROM, attendee.delegatedfrom) - put(JtxContract.JtxAttendee.DELEGATEDTO, attendee.delegatedto) - put(JtxContract.JtxAttendee.DIR, attendee.dir) - put(JtxContract.JtxAttendee.LANGUAGE, attendee.language) - put(JtxContract.JtxAttendee.MEMBER, attendee.member) - put(JtxContract.JtxAttendee.PARTSTAT, attendee.partstat) - put(JtxContract.JtxAttendee.ROLE, attendee.role) - put(JtxContract.JtxAttendee.RSVP, attendee.rsvp) - put(JtxContract.JtxAttendee.SENTBY, attendee.sentby) - put(JtxContract.JtxAttendee.OTHER, attendee.other) - } - collection.client.insert( - JtxContract.JtxAttendee.CONTENT_URI.asSyncAdapter( - collection.account - ), attendeeContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxAttendee.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxAttendee.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxAttendee.CALADDRESS, attendee.caladdress) + .withValue(JtxContract.JtxAttendee.CN, attendee.cn) + .withValue(JtxContract.JtxAttendee.CUTYPE, attendee.cutype) + .withValue(JtxContract.JtxAttendee.DELEGATEDFROM, attendee.delegatedfrom) + .withValue(JtxContract.JtxAttendee.DELEGATEDTO, attendee.delegatedto) + .withValue(JtxContract.JtxAttendee.DIR, attendee.dir) + .withValue(JtxContract.JtxAttendee.LANGUAGE, attendee.language) + .withValue(JtxContract.JtxAttendee.MEMBER, attendee.member) + .withValue(JtxContract.JtxAttendee.PARTSTAT, attendee.partstat) + .withValue(JtxContract.JtxAttendee.ROLE, attendee.role) + .withValue(JtxContract.JtxAttendee.RSVP, attendee.rsvp) + .withValue(JtxContract.JtxAttendee.SENTBY, attendee.sentby) + .withValue(JtxContract.JtxAttendee.OTHER, attendee.other) ) } - this.organizer.let { organizer -> - val organizerContentValues = ContentValues().apply { - put(JtxContract.JtxOrganizer.ICALOBJECT_ID, id) - put(JtxContract.JtxOrganizer.CALADDRESS, organizer?.caladdress) - - put(JtxContract.JtxOrganizer.CN, organizer?.cn) - put(JtxContract.JtxOrganizer.DIR, organizer?.dir) - put(JtxContract.JtxOrganizer.LANGUAGE, organizer?.language) - put(JtxContract.JtxOrganizer.SENTBY, organizer?.sentby) - put(JtxContract.JtxOrganizer.OTHER, organizer?.other) - } - collection.client.insert( - JtxContract.JtxOrganizer.CONTENT_URI.asSyncAdapter( - collection.account - ), organizerContentValues + this.organizer?.let { organizer -> + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxOrganizer.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxOrganizer.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxOrganizer.CALADDRESS, organizer.caladdress) + .withValue(JtxContract.JtxOrganizer.CN, organizer.cn) + .withValue(JtxContract.JtxOrganizer.DIR, organizer.dir) + .withValue(JtxContract.JtxOrganizer.LANGUAGE, organizer.language) + .withValue(JtxContract.JtxOrganizer.SENTBY, organizer.sentby) + .withValue(JtxContract.JtxOrganizer.OTHER, organizer.other) ) } @@ -1278,11 +1379,7 @@ duration?.let(props::add) put(JtxContract.JtxAttachment.OTHER, attachment.other) put(JtxContract.JtxAttachment.FILENAME, attachment.filename) } - val newAttachment = collection.client.insert( - JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter( - collection.account - ), attachmentContentValues - ) + val newAttachment = collection.client.insert(JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter(collection.account), attachmentContentValues) if(attachment.uri.isNullOrEmpty() && newAttachment != null) { val attachmentPFD = collection.client.openFile(newAttachment, "w") ParcelFileDescriptor.AutoCloseOutputStream(attachmentPFD).write(Base64.decode(attachment.binary, Base64.DEFAULT)) @@ -1290,37 +1387,35 @@ duration?.let(props::add) } this.alarms.forEach { alarm -> - val alarmContentValues = ContentValues().apply { - put(JtxContract.JtxAlarm.ICALOBJECT_ID, id) - put(JtxContract.JtxAlarm.ACTION, alarm.action) - put(JtxContract.JtxAlarm.ATTACH, alarm.attach) - //put(JtxContract.JtxAlarm.ATTENDEE, alarm.attendee) - put(JtxContract.JtxAlarm.DESCRIPTION, alarm.description) - put(JtxContract.JtxAlarm.DURATION, alarm.duration) - put(JtxContract.JtxAlarm.REPEAT, alarm.repeat) - put(JtxContract.JtxAlarm.SUMMARY, alarm.summary) - put(JtxContract.JtxAlarm.TRIGGER_RELATIVE_TO, alarm.triggerRelativeTo) - put(JtxContract.JtxAlarm.TRIGGER_RELATIVE_DURATION, alarm.triggerRelativeDuration) - put(JtxContract.JtxAlarm.TRIGGER_TIME, alarm.triggerTime) - put(JtxContract.JtxAlarm.TRIGGER_TIMEZONE, alarm.triggerTimezone) - put(JtxContract.JtxAlarm.OTHER, alarm.other) - } - collection.client.insert( - JtxContract.JtxAlarm.CONTENT_URI.asSyncAdapter(collection.account), - alarmContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxAlarm.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxAlarm.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxAlarm.ACTION, alarm.action) + .withValue(JtxContract.JtxAlarm.ATTACH, alarm.attach) + //.withValue(JtxContract.JtxAlarm.ATTENDEE, alarm.attendee) + .withValue(JtxContract.JtxAlarm.DESCRIPTION, alarm.description) + .withValue(JtxContract.JtxAlarm.DURATION, alarm.duration) + .withValue(JtxContract.JtxAlarm.REPEAT, alarm.repeat) + .withValue(JtxContract.JtxAlarm.SUMMARY, alarm.summary) + .withValue(JtxContract.JtxAlarm.TRIGGER_RELATIVE_TO, alarm.triggerRelativeTo) + .withValue(JtxContract.JtxAlarm.TRIGGER_RELATIVE_DURATION, alarm.triggerRelativeDuration) + .withValue(JtxContract.JtxAlarm.TRIGGER_TIME, alarm.triggerTime) + .withValue(JtxContract.JtxAlarm.TRIGGER_TIMEZONE, alarm.triggerTimezone) + .withValue(JtxContract.JtxAlarm.OTHER, alarm.other) ) } this.unknown.forEach { unknown -> - val unknownContentValues = ContentValues().apply { - put(JtxContract.JtxUnknown.ICALOBJECT_ID, id) - put(JtxContract.JtxUnknown.UNKNOWN_VALUE, unknown.value) - } - collection.client.insert( - JtxContract.JtxUnknown.CONTENT_URI.asSyncAdapter(collection.account), - unknownContentValues + insertBatch.enqueue( + BatchOperation.CpoBuilder + .newInsert(JtxContract.JtxUnknown.CONTENT_URI.asSyncAdapter(collection.account)) + .withValue(JtxContract.JtxUnknown.ICALOBJECT_ID, id) + .withValue(JtxContract.JtxUnknown.UNKNOWN_VALUE, unknown.value) ) } + + insertBatch.commit() } /** @@ -1354,12 +1449,15 @@ duration?.let(props::add) this.locationAltrep = newData.locationAltrep this.geoLat = newData.geoLat this.geoLong = newData.geoLong + this.geofenceRadius = newData.geofenceRadius this.percent = newData.percent this.classification = newData.classification this.status = newData.status + this.xstatus = newData.xstatus this.priority = newData.priority this.color = newData.color this.url = newData.url + this.contact = newData.contact this.dtstart = newData.dtstart this.dtstartTimezone = newData.dtstartTimezone @@ -1375,6 +1473,7 @@ duration?.let(props::add) this.rdate = newData.rdate this.exdate = newData.exdate this.recurid = newData.recurid + this.recuridTimezone = newData.recuridTimezone this.categories = newData.categories @@ -1393,425 +1492,295 @@ duration?.let(props::add) * @param [values] The Content Values with the information about the JtxICalObject */ fun populateFromContentValues(values: ContentValues) { - values.getAsLong(JtxContract.JtxICalObject.ID)?.let { id -> this.id = id } - - values.getAsString(JtxContract.JtxICalObject.COMPONENT)?.let { component -> this.component = component } - values.getAsString(JtxContract.JtxICalObject.SUMMARY)?.let { summary -> this.summary = summary } - values.getAsString(JtxContract.JtxICalObject.DESCRIPTION)?.let { description -> this.description = description } - values.getAsLong(JtxContract.JtxICalObject.DTSTART)?.let { dtstart -> this.dtstart = dtstart } - values.getAsString(JtxContract.JtxICalObject.DTSTART_TIMEZONE)?.let { dtstartTimezone -> this.dtstartTimezone = dtstartTimezone } - values.getAsLong(JtxContract.JtxICalObject.DTEND)?.let { dtend -> this.dtend = dtend } - values.getAsString(JtxContract.JtxICalObject.DTEND_TIMEZONE)?.let { dtendTimezone -> this.dtendTimezone = dtendTimezone } - values.getAsString(JtxContract.JtxICalObject.STATUS)?.let { status -> this.status = status } - values.getAsString(JtxContract.JtxICalObject.CLASSIFICATION)?.let { classification -> this.classification = classification } - values.getAsString(JtxContract.JtxICalObject.URL)?.let { url -> this.url = url } - values.getAsDouble(JtxContract.JtxICalObject.GEO_LAT)?.let { geoLat -> this.geoLat = geoLat } - values.getAsDouble(JtxContract.JtxICalObject.GEO_LONG)?.let { geoLong -> this.geoLong = geoLong } - values.getAsString(JtxContract.JtxICalObject.LOCATION)?.let { location -> this.location = location } - values.getAsString(JtxContract.JtxICalObject.LOCATION_ALTREP)?.let { locationAltrep -> this.locationAltrep = locationAltrep } - values.getAsInteger(JtxContract.JtxICalObject.PERCENT)?.let { percent -> this.percent = percent } - values.getAsInteger(JtxContract.JtxICalObject.PRIORITY)?.let { priority -> this.priority = priority } - values.getAsLong(JtxContract.JtxICalObject.DUE)?.let { due -> this.due = due } - values.getAsString(JtxContract.JtxICalObject.DUE_TIMEZONE)?.let { dueTimezone -> this.dueTimezone = dueTimezone } - values.getAsLong(JtxContract.JtxICalObject.COMPLETED)?.let { completed -> this.completed = completed } - values.getAsString(JtxContract.JtxICalObject.COMPLETED_TIMEZONE)?.let { completedTimezone -> this.completedTimezone = completedTimezone } - values.getAsString(JtxContract.JtxICalObject.DURATION)?.let { duration -> this.duration = duration } - values.getAsString(JtxContract.JtxICalObject.UID)?.let { uid -> this.uid = uid } - values.getAsLong(JtxContract.JtxICalObject.CREATED)?.let { created -> this.created = created } - values.getAsLong(JtxContract.JtxICalObject.DTSTAMP)?.let { dtstamp -> this.dtstamp = dtstamp } - values.getAsLong(JtxContract.JtxICalObject.LAST_MODIFIED)?.let { lastModified -> this.lastModified = lastModified } - values.getAsLong(JtxContract.JtxICalObject.SEQUENCE)?.let { sequence -> this.sequence = sequence } - values.getAsInteger(JtxContract.JtxICalObject.COLOR)?.let { color -> this.color = color } - - values.getAsString(JtxContract.JtxICalObject.RRULE)?.let { rrule -> this.rrule = rrule } - values.getAsString(JtxContract.JtxICalObject.EXDATE)?.let { exdate -> this.exdate = exdate } - values.getAsString(JtxContract.JtxICalObject.RDATE)?.let { rdate -> this.rdate = rdate } - values.getAsString(JtxContract.JtxICalObject.RECURID)?.let { recurid -> this.recurid = recurid } - - this.collectionId = collection.id - values.getAsString(JtxContract.JtxICalObject.DIRTY)?.let { dirty -> this.dirty = dirty == "1" || dirty == "true" } - values.getAsString(JtxContract.JtxICalObject.DELETED)?.let { deleted -> this.deleted = deleted == "1" || deleted == "true" } - - values.getAsString(JtxContract.JtxICalObject.FILENAME)?.let { fileName -> this.fileName = fileName } - values.getAsString(JtxContract.JtxICalObject.ETAG)?.let { eTag -> this.eTag = eTag } - values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG)?.let { scheduleTag -> this.scheduleTag = scheduleTag } - values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?.let { flags -> this.flags = flags } - - - // Take care of categories - val categoriesContentValues = getCategoryContentValues() - categoriesContentValues.forEach { catValues -> - val category = Category().apply { - catValues.getAsLong(JtxContract.JtxCategory.ID)?.let { id -> this.categoryId = id } - catValues.getAsString(JtxContract.JtxCategory.TEXT)?.let { text -> this.text = text } - catValues.getAsString(JtxContract.JtxCategory.LANGUAGE)?.let { language -> this.language = language } - catValues.getAsString(JtxContract.JtxCategory.OTHER)?.let { other -> this.other = other } - } - categories.add(category) + values.getAsLong(JtxContract.JtxICalObject.ID)?.let { id -> this.id = id } + + values.getAsString(JtxContract.JtxICalObject.COMPONENT)?.let { component -> this.component = component } + values.getAsString(JtxContract.JtxICalObject.SUMMARY)?.let { summary -> this.summary = summary } + values.getAsString(JtxContract.JtxICalObject.DESCRIPTION)?.let { description -> this.description = description } + values.getAsLong(JtxContract.JtxICalObject.DTSTART)?.let { dtstart -> this.dtstart = dtstart } + values.getAsString(JtxContract.JtxICalObject.DTSTART_TIMEZONE)?.let { dtstartTimezone -> this.dtstartTimezone = dtstartTimezone } + values.getAsLong(JtxContract.JtxICalObject.DTEND)?.let { dtend -> this.dtend = dtend } + values.getAsString(JtxContract.JtxICalObject.DTEND_TIMEZONE)?.let { dtendTimezone -> this.dtendTimezone = dtendTimezone } + values.getAsString(JtxContract.JtxICalObject.STATUS)?.let { status -> this.status = status } + values.getAsString(JtxContract.JtxICalObject.EXTENDED_STATUS)?.let { xstatus -> this.xstatus = xstatus } + values.getAsString(JtxContract.JtxICalObject.CLASSIFICATION)?.let { classification -> this.classification = classification } + values.getAsString(JtxContract.JtxICalObject.URL)?.let { url -> this.url = url } + values.getAsString(JtxContract.JtxICalObject.CONTACT)?.let { contact -> this.contact = contact } + values.getAsDouble(JtxContract.JtxICalObject.GEO_LAT)?.let { geoLat -> this.geoLat = geoLat } + values.getAsDouble(JtxContract.JtxICalObject.GEO_LONG)?.let { geoLong -> this.geoLong = geoLong } + values.getAsString(JtxContract.JtxICalObject.LOCATION)?.let { location -> this.location = location } + values.getAsString(JtxContract.JtxICalObject.LOCATION_ALTREP)?.let { locationAltrep -> this.locationAltrep = locationAltrep } + values.getAsInteger(JtxContract.JtxICalObject.GEOFENCE_RADIUS)?.let { geofenceRadius -> this.geofenceRadius = geofenceRadius } + values.getAsInteger(JtxContract.JtxICalObject.PERCENT)?.let { percent -> this.percent = percent } + values.getAsInteger(JtxContract.JtxICalObject.PRIORITY)?.let { priority -> this.priority = priority } + values.getAsLong(JtxContract.JtxICalObject.DUE)?.let { due -> this.due = due } + values.getAsString(JtxContract.JtxICalObject.DUE_TIMEZONE)?.let { dueTimezone -> this.dueTimezone = dueTimezone } + values.getAsLong(JtxContract.JtxICalObject.COMPLETED)?.let { completed -> this.completed = completed } + values.getAsString(JtxContract.JtxICalObject.COMPLETED_TIMEZONE)?.let { completedTimezone -> this.completedTimezone = completedTimezone } + values.getAsString(JtxContract.JtxICalObject.DURATION)?.let { duration -> this.duration = duration } + values.getAsString(JtxContract.JtxICalObject.UID)?.let { uid -> this.uid = uid } + values.getAsLong(JtxContract.JtxICalObject.CREATED)?.let { created -> this.created = created } + values.getAsLong(JtxContract.JtxICalObject.DTSTAMP)?.let { dtstamp -> this.dtstamp = dtstamp } + values.getAsLong(JtxContract.JtxICalObject.LAST_MODIFIED)?.let { lastModified -> this.lastModified = lastModified } + values.getAsLong(JtxContract.JtxICalObject.SEQUENCE)?.let { sequence -> this.sequence = sequence } + values.getAsInteger(JtxContract.JtxICalObject.COLOR)?.let { color -> this.color = color } + + values.getAsString(JtxContract.JtxICalObject.RRULE)?.let { rrule -> this.rrule = rrule } + values.getAsString(JtxContract.JtxICalObject.EXDATE)?.let { exdate -> this.exdate = exdate } + values.getAsString(JtxContract.JtxICalObject.RDATE)?.let { rdate -> this.rdate = rdate } + values.getAsString(JtxContract.JtxICalObject.RECURID)?.let { recurid -> this.recurid = recurid } + values.getAsString(JtxContract.JtxICalObject.RECURID_TIMEZONE)?.let { recuridTimezone -> this.recuridTimezone = recuridTimezone } + + this.collectionId = collection.id + values.getAsString(JtxContract.JtxICalObject.DIRTY)?.let { dirty -> this.dirty = dirty == "1" || dirty == "true" } + values.getAsString(JtxContract.JtxICalObject.DELETED)?.let { deleted -> this.deleted = deleted == "1" || deleted == "true" } + + values.getAsString(JtxContract.JtxICalObject.FILENAME)?.let { fileName -> this.fileName = fileName } + values.getAsString(JtxContract.JtxICalObject.ETAG)?.let { eTag -> this.eTag = eTag } + values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG)?.let { scheduleTag -> this.scheduleTag = scheduleTag } + values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?.let { flags -> this.flags = flags } + + + // Take care of categories + getAsContentValues( + uri = JtxContract.JtxCategory.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxCategory.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).forEach { catValues -> + val category = Category().apply { + catValues.getAsLong(JtxContract.JtxCategory.ID)?.let { id -> this.categoryId = id } + catValues.getAsString(JtxContract.JtxCategory.TEXT)?.let { text -> this.text = text } + catValues.getAsString(JtxContract.JtxCategory.LANGUAGE)?.let { language -> this.language = language } + catValues.getAsString(JtxContract.JtxCategory.OTHER)?.let { other -> this.other = other } } + categories.add(category) + } - // Take care of comments - val commentsContentValues = getCommentContentValues() - commentsContentValues.forEach { commentValues -> - val comment = Comment().apply { - commentValues.getAsLong(JtxContract.JtxComment.ID)?.let { id -> this.commentId = id } - commentValues.getAsString(JtxContract.JtxComment.TEXT)?.let { text -> this.text = text } - commentValues.getAsString(JtxContract.JtxComment.LANGUAGE)?.let { language -> this.language = language } - commentValues.getAsString(JtxContract.JtxComment.OTHER)?.let { other -> this.other = other } - } - comments.add(comment) + // Take care of comments + getAsContentValues( + uri = JtxContract.JtxComment.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxComment.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).forEach { commentValues -> + val comment = Comment().apply { + commentValues.getAsLong(JtxContract.JtxComment.ID)?.let { id -> this.commentId = id } + commentValues.getAsString(JtxContract.JtxComment.TEXT)?.let { text -> this.text = text } + commentValues.getAsString(JtxContract.JtxComment.LANGUAGE)?.let { language -> this.language = language } + commentValues.getAsString(JtxContract.JtxComment.OTHER)?.let { other -> this.other = other } } + comments.add(comment) + } - // Take care of resources - val resourceContentValues = getResourceContentValues() - resourceContentValues.forEach { resourceValues -> - val resource = Resource().apply { - resourceValues.getAsLong(JtxContract.JtxResource.ID)?.let { id -> this.resourceId = id } - resourceValues.getAsString(JtxContract.JtxResource.TEXT)?.let { text -> this.text = text } - resourceValues.getAsString(JtxContract.JtxResource.LANGUAGE)?.let { language -> this.language = language } - resourceValues.getAsString(JtxContract.JtxResource.OTHER)?.let { other -> this.other = other } - } - resources.add(resource) + // Take care of resources + getAsContentValues( + uri = JtxContract.JtxResource.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxResource.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).forEach { resourceValues -> + val resource = Resource().apply { + resourceValues.getAsLong(JtxContract.JtxResource.ID)?.let { id -> this.resourceId = id } + resourceValues.getAsString(JtxContract.JtxResource.TEXT)?.let { text -> this.text = text } + resourceValues.getAsString(JtxContract.JtxResource.LANGUAGE)?.let { language -> this.language = language } + resourceValues.getAsString(JtxContract.JtxResource.OTHER)?.let { other -> this.other = other } } + resources.add(resource) + } - // Take care of related-to - val relatedToContentValues = getRelatedToContentValues() - relatedToContentValues.forEach { relatedToValues -> - val relTo = RelatedTo().apply { - relatedToValues.getAsLong(JtxContract.JtxRelatedto.ID)?.let { id -> this.relatedtoId = id } - relatedToValues.getAsString(JtxContract.JtxRelatedto.TEXT)?.let { text -> this.text = text } - relatedToValues.getAsString(JtxContract.JtxRelatedto.RELTYPE)?.let { reltype -> this.reltype = reltype } - relatedToValues.getAsString(JtxContract.JtxRelatedto.OTHER)?.let { other -> this.other = other } + // Take care of related-to + getAsContentValues( + uri = JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.RELTYPE} = ?", + selectionArgs = arrayOf(this.id.toString(), JtxContract.JtxRelatedto.Reltype.PARENT.name) + ).forEach { relatedToValues -> + val relTo = RelatedTo().apply { + relatedToValues.getAsLong(JtxContract.JtxRelatedto.ID)?.let { id -> this.relatedtoId = id } + relatedToValues.getAsString(JtxContract.JtxRelatedto.TEXT)?.let { text -> this.text = text } + relatedToValues.getAsString(JtxContract.JtxRelatedto.RELTYPE)?.let { reltype -> this.reltype = reltype } + relatedToValues.getAsString(JtxContract.JtxRelatedto.OTHER)?.let { other -> this.other = other } - } - relatedTo.add(relTo) } + relatedTo.add(relTo) + } - - // Take care of attendees - val attendeeContentValues = getAttendeesContentValues() - attendeeContentValues.forEach { attendeeValues -> - val attendee = Attendee().apply { - attendeeValues.getAsLong(JtxContract.JtxAttendee.ID)?.let { id -> this.attendeeId = id } - attendeeValues.getAsString(JtxContract.JtxAttendee.CALADDRESS)?.let { caladdress -> this.caladdress = caladdress } - attendeeValues.getAsString(JtxContract.JtxAttendee.CUTYPE)?.let { cutype -> this.cutype = cutype } - attendeeValues.getAsString(JtxContract.JtxAttendee.MEMBER)?.let { member -> this.member = member } - attendeeValues.getAsString(JtxContract.JtxAttendee.ROLE)?.let { role -> this.role = role } - attendeeValues.getAsString(JtxContract.JtxAttendee.PARTSTAT)?.let { partstat -> this.partstat = partstat } - attendeeValues.getAsString(JtxContract.JtxAttendee.RSVP)?.let { rsvp -> this.rsvp = rsvp == "1" } - attendeeValues.getAsString(JtxContract.JtxAttendee.DELEGATEDTO)?.let { delto -> this.delegatedto = delto } - attendeeValues.getAsString(JtxContract.JtxAttendee.DELEGATEDFROM)?.let { delfrom -> this.delegatedfrom = delfrom } - attendeeValues.getAsString(JtxContract.JtxAttendee.SENTBY)?.let { sentby -> this.sentby = sentby } - attendeeValues.getAsString(JtxContract.JtxAttendee.CN)?.let { cn -> this.cn = cn } - attendeeValues.getAsString(JtxContract.JtxAttendee.DIR)?.let { dir -> this.dir = dir } - attendeeValues.getAsString(JtxContract.JtxAttendee.LANGUAGE)?.let { lang -> this.language = lang } - attendeeValues.getAsString(JtxContract.JtxAttendee.OTHER)?.let { other -> this.other = other } - } - attendees.add(attendee) + // Take care of attendees + getAsContentValues( + uri = JtxContract.JtxAttendee.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxAttendee.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).forEach { attendeeValues -> + val attendee = Attendee().apply { + attendeeValues.getAsLong(JtxContract.JtxAttendee.ID)?.let { id -> this.attendeeId = id } + attendeeValues.getAsString(JtxContract.JtxAttendee.CALADDRESS)?.let { caladdress -> this.caladdress = caladdress } + attendeeValues.getAsString(JtxContract.JtxAttendee.CUTYPE)?.let { cutype -> this.cutype = cutype } + attendeeValues.getAsString(JtxContract.JtxAttendee.MEMBER)?.let { member -> this.member = member } + attendeeValues.getAsString(JtxContract.JtxAttendee.ROLE)?.let { role -> this.role = role } + attendeeValues.getAsString(JtxContract.JtxAttendee.PARTSTAT)?.let { partstat -> this.partstat = partstat } + attendeeValues.getAsString(JtxContract.JtxAttendee.RSVP)?.let { rsvp -> this.rsvp = rsvp == "1" } + attendeeValues.getAsString(JtxContract.JtxAttendee.DELEGATEDTO)?.let { delto -> this.delegatedto = delto } + attendeeValues.getAsString(JtxContract.JtxAttendee.DELEGATEDFROM)?.let { delfrom -> this.delegatedfrom = delfrom } + attendeeValues.getAsString(JtxContract.JtxAttendee.SENTBY)?.let { sentby -> this.sentby = sentby } + attendeeValues.getAsString(JtxContract.JtxAttendee.CN)?.let { cn -> this.cn = cn } + attendeeValues.getAsString(JtxContract.JtxAttendee.DIR)?.let { dir -> this.dir = dir } + attendeeValues.getAsString(JtxContract.JtxAttendee.LANGUAGE)?.let { lang -> this.language = lang } + attendeeValues.getAsString(JtxContract.JtxAttendee.OTHER)?.let { other -> this.other = other } } + attendees.add(attendee) + } - // Take care of organizer - val organizerContentValues = getOrganizerContentValues() + // Take care of organizer + getAsContentValues( + uri = JtxContract.JtxOrganizer.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxOrganizer.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).firstOrNull()?.let { organizerContentValues -> val orgnzr = Organizer().apply { - organizerId = organizerContentValues?.getAsLong(JtxContract.JtxOrganizer.ID) ?: 0L - caladdress = organizerContentValues?.getAsString(JtxContract.JtxOrganizer.CALADDRESS) - sentby = organizerContentValues?.getAsString(JtxContract.JtxOrganizer.SENTBY) - cn = organizerContentValues?.getAsString(JtxContract.JtxOrganizer.CN) - dir = organizerContentValues?.getAsString(JtxContract.JtxOrganizer.DIR) - language = organizerContentValues?.getAsString(JtxContract.JtxOrganizer.LANGUAGE) - other = organizerContentValues?.getAsString(JtxContract.JtxOrganizer.OTHER) + organizerId = organizerContentValues.getAsLong(JtxContract.JtxOrganizer.ID) ?: 0L + caladdress = organizerContentValues.getAsString(JtxContract.JtxOrganizer.CALADDRESS) + sentby = organizerContentValues.getAsString(JtxContract.JtxOrganizer.SENTBY) + cn = organizerContentValues.getAsString(JtxContract.JtxOrganizer.CN) + dir = organizerContentValues.getAsString(JtxContract.JtxOrganizer.DIR) + language = organizerContentValues.getAsString(JtxContract.JtxOrganizer.LANGUAGE) + other = organizerContentValues.getAsString(JtxContract.JtxOrganizer.OTHER) } if(orgnzr.caladdress?.isNotEmpty() == true) // we only take the organizer if there was a caladdress (otherwise an empty ORGANIZER is created) organizer = orgnzr - - // Take care of attachments - val attachmentContentValues = getAttachmentsContentValues() - attachmentContentValues.forEach { attachmentValues -> - val attachment = Attachment().apply { - attachmentValues.getAsLong(JtxContract.JtxAttachment.ID)?.let { id -> this.attachmentId = id } - attachmentValues.getAsString(JtxContract.JtxAttachment.URI)?.let { uri -> this.uri = uri } - attachmentValues.getAsString(JtxContract.JtxAttachment.BINARY)?.let { value -> this.binary = value } - attachmentValues.getAsString(JtxContract.JtxAttachment.FMTTYPE)?.let { fmttype -> this.fmttype = fmttype } - attachmentValues.getAsString(JtxContract.JtxAttachment.OTHER)?.let { other -> this.other = other } - attachmentValues.getAsString(JtxContract.JtxAttachment.FILENAME)?.let { filename -> this.filename = filename } - } - attachments.add(attachment) - } - - // Take care of alarms - val alarmContentValues = getAlarmsContentValues() - alarmContentValues.forEach { alarmValues -> - val alarm = Alarm().apply { - alarmValues.getAsLong(JtxContract.JtxAlarm.ID)?.let { id -> this.alarmId = id } - alarmValues.getAsString(JtxContract.JtxAlarm.ACTION)?.let { action -> this.action = action } - alarmValues.getAsString(JtxContract.JtxAlarm.DESCRIPTION)?.let { desc -> this.description = desc } - alarmValues.getAsLong(JtxContract.JtxAlarm.TRIGGER_TIME)?.let { time -> this.triggerTime = time } - alarmValues.getAsString(JtxContract.JtxAlarm.TRIGGER_TIMEZONE)?.let { tz -> this.triggerTimezone = tz } - alarmValues.getAsString(JtxContract.JtxAlarm.TRIGGER_RELATIVE_TO)?.let { relative -> this.triggerRelativeTo = relative } - alarmValues.getAsString(JtxContract.JtxAlarm.TRIGGER_RELATIVE_DURATION)?.let { duration -> this.triggerRelativeDuration = duration } - alarmValues.getAsString(JtxContract.JtxAlarm.SUMMARY)?.let { summary -> this.summary = summary } - alarmValues.getAsString(JtxContract.JtxAlarm.DURATION)?.let { dur -> this.duration = dur } - alarmValues.getAsString(JtxContract.JtxAlarm.REPEAT)?.let { repeat -> this.repeat = repeat } - alarmValues.getAsString(JtxContract.JtxAlarm.ATTACH)?.let { attach -> this.attach = attach } - alarmValues.getAsString(JtxContract.JtxAlarm.OTHER)?.let { other -> this.other = other } - } - alarms.add(alarm) - } - - // Take care of uknown properties - val unknownContentValues = getUnknownContentValues() - unknownContentValues.forEach { unknownValues -> - val unknwn = Unknown().apply { - unknownValues.getAsLong(JtxContract.JtxUnknown.ID)?.let { id -> this.unknownId = id } - unknownValues.getAsString(JtxContract.JtxUnknown.UNKNOWN_VALUE)?.let { value -> this.value = value } - } - unknown.add(unknwn) } - } - - /** - * Puts the current JtxICalObjects attributes into Content Values - * @return The JtxICalObject attributes as [ContentValues] (exluding list properties) - */ - private fun toContentValues(): ContentValues { - val values = ContentValues() - values.put(JtxContract.JtxICalObject.ID, id) - summary.let { values.put(JtxContract.JtxICalObject.SUMMARY, it) } - description.let { values.put(JtxContract.JtxICalObject.DESCRIPTION, it) } - values.put(JtxContract.JtxICalObject.COMPONENT, component) - status.let { values.put(JtxContract.JtxICalObject.STATUS, it) } - classification.let { values.put(JtxContract.JtxICalObject.CLASSIFICATION, it) } - priority.let { values.put(JtxContract.JtxICalObject.PRIORITY, it) } - values.put(JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID, collectionId) - values.put(JtxContract.JtxICalObject.UID, uid) - values.put(JtxContract.JtxICalObject.COLOR, color) - values.put(JtxContract.JtxICalObject.URL, url) - geoLat.let { values.put(JtxContract.JtxICalObject.GEO_LAT, it) } - geoLong.let { values.put(JtxContract.JtxICalObject.GEO_LONG, it) } - location.let { values.put(JtxContract.JtxICalObject.LOCATION, it) } - locationAltrep.let { values.put(JtxContract.JtxICalObject.LOCATION_ALTREP, it) } - percent.let { values.put(JtxContract.JtxICalObject.PERCENT, it) } - values.put(JtxContract.JtxICalObject.DTSTAMP, dtstamp) - dtstart.let { values.put(JtxContract.JtxICalObject.DTSTART, it) } - dtstartTimezone.let { values.put(JtxContract.JtxICalObject.DTSTART_TIMEZONE, it) } - dtend.let { values.put(JtxContract.JtxICalObject.DTEND, it) } - dtendTimezone.let { values.put(JtxContract.JtxICalObject.DTEND_TIMEZONE, it) } - completed.let { values.put(JtxContract.JtxICalObject.COMPLETED, it) } - completedTimezone.let { values.put(JtxContract.JtxICalObject.COMPLETED_TIMEZONE, it) } - due.let { values.put(JtxContract.JtxICalObject.DUE, it) } - dueTimezone.let { values.put(JtxContract.JtxICalObject.DUE_TIMEZONE, it) } - duration.let { values.put(JtxContract.JtxICalObject.DURATION, it) } - - created.let { values.put(JtxContract.JtxICalObject.CREATED, it) } - lastModified.let { values.put(JtxContract.JtxICalObject.LAST_MODIFIED, it) } - sequence.let { values.put(JtxContract.JtxICalObject.SEQUENCE, it) } - - rrule.let { values.put(JtxContract.JtxICalObject.RRULE, it) } - rdate.let { values.put(JtxContract.JtxICalObject.RDATE, it) } - exdate.let { values.put(JtxContract.JtxICalObject.EXDATE, it) } - recurid.let { values.put(JtxContract.JtxICalObject.RECURID, it) } - - fileName.let { values.put(JtxContract.JtxICalObject.FILENAME, it) } - eTag.let { values.put(JtxContract.JtxICalObject.ETAG, it) } - scheduleTag.let { values.put(JtxContract.JtxICalObject.SCHEDULETAG, it) } - values.put(JtxContract.JtxICalObject.FLAGS, flags) - values.put(JtxContract.JtxICalObject.DIRTY, dirty) - - return values - } - - - /** - * @return The categories of the given JtxICalObject as a list of ContentValues - */ - private fun getCategoryContentValues(): List { - - val categoryUrl = JtxContract.JtxCategory.CONTENT_URI.asSyncAdapter(collection.account) - val categoryValues: MutableList = mutableListOf() - collection.client.query( - categoryUrl, - null, - "${JtxContract.JtxCategory.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - categoryValues.add(cursor.toValues()) + // Take care of attachments + getAsContentValues( + uri = JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxAttachment.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).forEach { attachmentValues -> + val attachment = Attachment().apply { + attachmentValues.getAsLong(JtxContract.JtxAttachment.ID)?.let { id -> this.attachmentId = id } + attachmentValues.getAsString(JtxContract.JtxAttachment.URI)?.let { uri -> this.uri = uri } + attachmentValues.getAsString(JtxContract.JtxAttachment.BINARY)?.let { value -> this.binary = value } + attachmentValues.getAsString(JtxContract.JtxAttachment.FMTTYPE)?.let { fmttype -> this.fmttype = fmttype } + attachmentValues.getAsString(JtxContract.JtxAttachment.OTHER)?.let { other -> this.other = other } + attachmentValues.getAsString(JtxContract.JtxAttachment.FILENAME)?.let { filename -> this.filename = filename } } + attachments.add(attachment) } - return categoryValues - } - - /** - * @return The comments of the given JtxICalObject as a list of ContentValues - */ - private fun getCommentContentValues(): List { - - val commentUrl = JtxContract.JtxComment.CONTENT_URI.asSyncAdapter(collection.account) - val commentValues: MutableList = mutableListOf() - collection.client.query( - commentUrl, - null, - "${JtxContract.JtxComment.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - commentValues.add(cursor.toValues()) + // Take care of alarms + getAsContentValues( + uri = JtxContract.JtxAlarm.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxAlarm.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()) + ).forEach { alarmValues -> + val alarm = Alarm().apply { + alarmValues.getAsLong(JtxContract.JtxAlarm.ID)?.let { id -> this.alarmId = id } + alarmValues.getAsString(JtxContract.JtxAlarm.ACTION)?.let { action -> this.action = action } + alarmValues.getAsString(JtxContract.JtxAlarm.DESCRIPTION)?.let { desc -> this.description = desc } + alarmValues.getAsLong(JtxContract.JtxAlarm.TRIGGER_TIME)?.let { time -> this.triggerTime = time } + alarmValues.getAsString(JtxContract.JtxAlarm.TRIGGER_TIMEZONE)?.let { tz -> this.triggerTimezone = tz } + alarmValues.getAsString(JtxContract.JtxAlarm.TRIGGER_RELATIVE_TO)?.let { relative -> this.triggerRelativeTo = relative } + alarmValues.getAsString(JtxContract.JtxAlarm.TRIGGER_RELATIVE_DURATION)?.let { duration -> this.triggerRelativeDuration = duration } + alarmValues.getAsString(JtxContract.JtxAlarm.SUMMARY)?.let { summary -> this.summary = summary } + alarmValues.getAsString(JtxContract.JtxAlarm.DURATION)?.let { dur -> this.duration = dur } + alarmValues.getAsString(JtxContract.JtxAlarm.REPEAT)?.let { repeat -> this.repeat = repeat } + alarmValues.getAsString(JtxContract.JtxAlarm.ATTACH)?.let { attach -> this.attach = attach } + alarmValues.getAsString(JtxContract.JtxAlarm.OTHER)?.let { other -> this.other = other } } + alarms.add(alarm) } - return commentValues - } - /** - * @return The resources of the given JtxICalObject as a list of ContentValues - */ - private fun getResourceContentValues(): List { - - val resourceUrl = JtxContract.JtxResource.CONTENT_URI.asSyncAdapter(collection.account) - val resourceValues: MutableList = mutableListOf() - collection.client.query( - resourceUrl, - null, - "${JtxContract.JtxResource.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - resourceValues.add(cursor.toValues()) - } - } - return resourceValues - } - /** - * @return The RelatedTo of the given JtxICalObject as a list of ContentValues - */ - private fun getRelatedToContentValues(): List { - - val relatedToUrl = JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(collection.account) - val relatedToValues: MutableList = mutableListOf() - collection.client.query( - relatedToUrl, - null, - "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.RELTYPE} = ?", - arrayOf(this.id.toString(), JtxContract.JtxRelatedto.Reltype.PARENT.name), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - relatedToValues.add(cursor.toValues()) + // Take care of unknown properties + getAsContentValues( + uri = JtxContract.JtxUnknown.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxUnknown.ICALOBJECT_ID} = ?", + selectionArgs = arrayOf(this.id.toString()), + ).forEach { unknownValues -> + val unknwn = Unknown().apply { + unknownValues.getAsLong(JtxContract.JtxUnknown.ID)?.let { id -> this.unknownId = id } + unknownValues.getAsString(JtxContract.JtxUnknown.UNKNOWN_VALUE)?.let { value -> this.value = value } } + unknown.add(unknwn) } - return relatedToValues - } - /** - * @return The attendees of the given JtxICalObject as a list of ContentValues - */ - private fun getAttendeesContentValues(): List { - - val attendeesUrl = JtxContract.JtxAttendee.CONTENT_URI.asSyncAdapter(collection.account) - val attendeesValues: MutableList = mutableListOf() - collection.client.query( - attendeesUrl, - null, - "${JtxContract.JtxAttendee.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - attendeesValues.add(cursor.toValues()) - } - } - return attendeesValues - } - /** - * @return The organizer of the given JtxICalObject as ContentValues - */ - private fun getOrganizerContentValues(): ContentValues? { - - val organizerUrl = JtxContract.JtxOrganizer.CONTENT_URI.asSyncAdapter(collection.account) - collection.client.query( - organizerUrl, - null, - "${JtxContract.JtxOrganizer.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - if(cursor.moveToFirst()) { - return cursor.toValues() + if(rrule?.isNotEmpty() == true) { + getAsContentValues( + uri = JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(collection.account), + selection = "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", + selectionArgs = arrayOf(uid) + ).forEach { recurInstanceValues -> + recurInstances.add( + JtxICalObject(collection).apply { populateFromContentValues(recurInstanceValues) } + ) } } - return null } /** - * @return The attachments of the given JtxICalObject as a list of ContentValues - */ - private fun getAttachmentsContentValues(): List { - - val attachmentsUrl = - JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter(collection.account) - val attachmentsValues: MutableList = mutableListOf() - collection.client.query( - attachmentsUrl, - null, - "${JtxContract.JtxAttachment.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - attachmentsValues.add(cursor.toValues()) - } - } - return attachmentsValues - } - - /** - * @return The alarms of the given JtxICalObject as a list of ContentValues + * Puts the current JtxICalObjects attributes into Content Values + * @return The JtxICalObject attributes as [ContentValues] (exluding list properties) */ - private fun getAlarmsContentValues(): List { - - val alarmsUrl = - JtxContract.JtxAlarm.CONTENT_URI.asSyncAdapter(collection.account) - val alarmValues: MutableList = mutableListOf() - collection.client.query( - alarmsUrl, - null, - "${JtxContract.JtxAlarm.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null - )?.use { cursor -> - while (cursor.moveToNext()) { - alarmValues.add(cursor.toValues()) - } - } - return alarmValues + private fun toContentValues() = ContentValues().apply { + put(JtxContract.JtxICalObject.ID, id) + put(JtxContract.JtxICalObject.SUMMARY, summary) + put(JtxContract.JtxICalObject.DESCRIPTION, description) + put(JtxContract.JtxICalObject.COMPONENT, component) + put(JtxContract.JtxICalObject.STATUS, status) + put(JtxContract.JtxICalObject.EXTENDED_STATUS, xstatus) + put(JtxContract.JtxICalObject.CLASSIFICATION, classification) + put(JtxContract.JtxICalObject.PRIORITY, priority) + put(JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID, collectionId) + put(JtxContract.JtxICalObject.UID, uid) + put(JtxContract.JtxICalObject.COLOR, color) + put(JtxContract.JtxICalObject.URL, url) + put(JtxContract.JtxICalObject.CONTACT, contact) + put(JtxContract.JtxICalObject.GEO_LAT, geoLat) + put(JtxContract.JtxICalObject.GEO_LONG, geoLong) + put(JtxContract.JtxICalObject.LOCATION, location) + put(JtxContract.JtxICalObject.LOCATION_ALTREP, locationAltrep) + put(JtxContract.JtxICalObject.GEOFENCE_RADIUS, geofenceRadius) + put(JtxContract.JtxICalObject.PERCENT, percent) + put(JtxContract.JtxICalObject.DTSTAMP, dtstamp) + put(JtxContract.JtxICalObject.DTSTART, dtstart) + put(JtxContract.JtxICalObject.DTSTART_TIMEZONE, dtstartTimezone) + put(JtxContract.JtxICalObject.DTEND, dtend) + put(JtxContract.JtxICalObject.DTEND_TIMEZONE, dtendTimezone) + put(JtxContract.JtxICalObject.COMPLETED, completed) + put(JtxContract.JtxICalObject.COMPLETED_TIMEZONE, completedTimezone) + put(JtxContract.JtxICalObject.DUE, due) + put(JtxContract.JtxICalObject.DUE_TIMEZONE, dueTimezone) + put(JtxContract.JtxICalObject.DURATION, duration) + put(JtxContract.JtxICalObject.CREATED, created) + put(JtxContract.JtxICalObject.LAST_MODIFIED, lastModified) + put(JtxContract.JtxICalObject.SEQUENCE, sequence) + put(JtxContract.JtxICalObject.RRULE, rrule) + put(JtxContract.JtxICalObject.RDATE, rdate) + put(JtxContract.JtxICalObject.EXDATE, exdate) + put(JtxContract.JtxICalObject.RECURID, recurid) + put(JtxContract.JtxICalObject.RECURID_TIMEZONE, recuridTimezone) + + put(JtxContract.JtxICalObject.FILENAME, fileName) + put(JtxContract.JtxICalObject.ETAG, eTag) + put(JtxContract.JtxICalObject.SCHEDULETAG, scheduleTag) + put(JtxContract.JtxICalObject.FLAGS, flags) + put(JtxContract.JtxICalObject.DIRTY, dirty) } /** - * @return The unknown properties of the given JtxICalObject as a list of ContentValues + * @return The result of the given query as content values of the given JtxICalObject as a list of ContentValues */ - private fun getUnknownContentValues(): List { - - val unknownUrl = - JtxContract.JtxUnknown.CONTENT_URI.asSyncAdapter(collection.account) - val unknownValues: MutableList = mutableListOf() - collection.client.query( - unknownUrl, - null, - "${JtxContract.JtxUnknown.ICALOBJECT_ID} = ?", - arrayOf(this.id.toString()), - null + private fun getAsContentValues( + uri: Uri, + projection: Array? = null, + selection: String, + selectionArgs: Array, + sortOrder: String? = null + ): List { + + val values: MutableList = mutableListOf() + collection.client.query(uri, projection, selection, selectionArgs, sortOrder )?.use { cursor -> - while (cursor.moveToNext()) { - unknownValues.add(cursor.toValues()) - } + while (cursor.moveToNext()) { values.add(cursor.toValues()) } } - return unknownValues + return values } } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObjectFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObjectFactory.kt new file mode 100644 index 00000000..fa3242a2 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObjectFactory.kt @@ -0,0 +1,15 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import android.content.ContentValues + +interface JtxICalObjectFactory { + + fun fromProvider(collection: JtxCollection, values: ContentValues): T + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt similarity index 64% rename from src/main/java/at/bitfire/ical4android/Task.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index b41f23a9..ead56412 100644 --- a/src/main/java/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -8,61 +10,96 @@ import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException -import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.TextList import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VToDo -import net.fortuna.ical4j.model.property.* +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Comment +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtStamp +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import net.fortuna.ical4j.model.property.Version import java.io.IOException import java.io.OutputStream import java.io.Reader import java.net.URI import java.net.URISyntaxException -import java.util.* +import java.util.LinkedList import java.util.logging.Level +import java.util.logging.Logger -class Task: ICalendar() { +data class Task( + var createdAt: Long? = null, + var lastModified: Long? = null, - var createdAt: Long? = null - var lastModified: Long? = null - - var summary: String? = null - var location: String? = null - var geoPosition: Geo? = null - var description: String? = null - var color: Int? = null - var url: String? = null - var organizer: Organizer? = null + var summary: String? = null, + var location: String? = null, + var geoPosition: Geo? = null, + var description: String? = null, + var color: Int? = null, + var url: String? = null, + var organizer: Organizer? = null, @IntRange(from = 0, to = 9) - var priority: Int = Priority.UNDEFINED.level + var priority: Int = Priority.UNDEFINED.level, - var classification: Clazz? = null - var status: Status? = null + var classification: Clazz? = null, + var status: Status? = null, - var dtStart: DtStart? = null - var due: Due? = null - var duration: Duration? = null - var completedAt: Completed? = null + var dtStart: DtStart? = null, + var due: Due? = null, + var duration: Duration? = null, + var completedAt: Completed? = null, @IntRange(from = 0, to = 100) - var percentComplete: Int? = null + var percentComplete: Int? = null, - var rRule: RRule? = null - val rDates = LinkedList() - val exDates = LinkedList() + var rRule: RRule? = null, + val rDates: LinkedList = LinkedList(), + val exDates: LinkedList = LinkedList(), - val categories = LinkedList() - var relatedTo = LinkedList() - val unknownProperties = LinkedList() + val categories: LinkedList = LinkedList(), + var comment: String? = null, + var relatedTo: LinkedList = LinkedList(), + val unknownProperties: LinkedList = LinkedList(), - val alarms = LinkedList() + val alarms: LinkedList = LinkedList(), +) : ICalendar() { companion object { + private val logger + get() = Logger.getLogger(Task::class.java.name) + /** - * Parses an iCalendar resource, applies [ICalPreprocessor] to increase compatibility + * Parses an iCalendar resource, applies [at.bitfire.ical4android.validation.ICalPreprocessor] to increase compatibility * and extracts the VTODOs. * * @param reader where the iCalendar is taken from @@ -73,7 +110,6 @@ class Task: ICalendar() { * @throws IllegalArgumentException when the iCalendar resource contains an invalid value * @throws IOException on I/O errors */ - @UsesThreadContextClassLoader fun tasksFromReader(reader: Reader): List { val ical = fromReader(reader) val vToDos = ical.getComponents(Component.VTODO) @@ -86,7 +122,7 @@ class Task: ICalendar() { if (todo.uid != null) t.uid = todo.uid.value else { - Ical4Android.log.warning("Received VTODO without UID, generating new one") + logger.warning("Received VTODO without UID, generating new one") t.generateUID() } @@ -119,6 +155,7 @@ class Task: ICalendar() { is Categories -> for (category in prop.categories) t.categories += category + is Comment -> t.comment = prop.value is RelatedTo -> t.relatedTo.add(prop) is Uid, is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } else -> t.unknownProperties += prop @@ -132,22 +169,22 @@ class Task: ICalendar() { if (dtStart != null && due != null) { if (DateUtils.isDate(dtStart) && DateUtils.isDateTime(due)) { - Ical4Android.log.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") + logger.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") t.dtStart = DtStart(DateTime(dtStart.value, due.timeZone)) } else if (DateUtils.isDateTime(dtStart) && DateUtils.isDate(due)) { - Ical4Android.log.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") + logger.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") t.due = Due(DateTime(due.value, dtStart.timeZone)) } if (due.date < dtStart.date) { - Ical4Android.log.warning("Found invalid DUE < DTSTART; dropping DTSTART") + logger.warning("Found invalid DUE < DTSTART; dropping DTSTART") t.dtStart = null } } if (t.duration != null && t.dtStart == null) { - Ical4Android.log.warning("Found DURATION without DTSTART; ignoring") + logger.warning("Found DURATION without DTSTART; ignoring") t.duration = null } @@ -157,10 +194,7 @@ class Task: ICalendar() { } - @UsesThreadContextClassLoader fun write(os: OutputStream) { - Ical4Android.checkThreadContextClassLoader() - val ical = Calendar() ical.properties += Version.VERSION_2_0 ical.properties += prodId() @@ -187,7 +221,7 @@ class Task: ICalendar() { try { props += Url(URI(it)) } catch (e: URISyntaxException) { - Ical4Android.log.log(Level.WARNING, "Ignoring invalid task URL: $url", e) + logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) } } organizer?.let { props += it } @@ -203,6 +237,7 @@ class Task: ICalendar() { if (categories.isNotEmpty()) props += Categories(TextList(categories.toTypedArray())) + comment?.let { props += Comment(it) } props.addAll(relatedTo) props.addAll(unknownProperties) @@ -224,7 +259,7 @@ class Task: ICalendar() { percentComplete?.let { props += PercentComplete(it) } if (alarms.isNotEmpty()) - vTodo.alarms.addAll(alarms) + vTodo.components.addAll(alarms) // determine earliest referenced date val earliest = arrayOf( diff --git a/src/main/java/at/bitfire/ical4android/TaskProvider.kt b/lib/src/main/kotlin/at/bitfire/ical4android/TaskProvider.kt similarity index 83% rename from src/main/java/at/bitfire/ical4android/TaskProvider.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/TaskProvider.kt index ccb88790..e06058c2 100644 --- a/src/main/java/at/bitfire/ical4android/TaskProvider.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/TaskProvider.kt @@ -1,6 +1,8 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android @@ -9,10 +11,11 @@ import android.content.ContentProviderClient import android.content.Context import android.content.pm.PackageManager import androidx.core.content.pm.PackageInfoCompat -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat -import org.dmfs.tasks.contract.TaskContract +import at.bitfire.ical4android.util.MiscUtils.closeCompat import java.io.Closeable import java.util.logging.Level +import java.util.logging.Logger +import org.dmfs.tasks.contract.TaskContract class TaskProvider private constructor( @@ -21,14 +24,14 @@ class TaskProvider private constructor( ): Closeable { enum class ProviderName( - val authority: String, - val packageName: String, - val minVersionCode: Long, - val minVersionName: String, - private val readPermission: String, - private val writePermission: String + val authority: String, + val packageName: String, + val minVersionCode: Long, + val minVersionName: String, + private val readPermission: String, + private val writePermission: String ) { - JtxBoard("at.techbee.jtx.provider", "at.techbee.jtx", 101010006, "1.01.01", PERMISSION_JTX_READ, PERMISSION_JTX_WRITE), + JtxBoard("at.techbee.jtx.provider", "at.techbee.jtx", 210000000, "2.10.00", PERMISSION_JTX_READ, PERMISSION_JTX_WRITE), TasksOrg("org.tasks.opentasks", "org.tasks", 100000, "10.0", PERMISSION_TASKS_ORG_READ, PERMISSION_TASKS_ORG_WRITE), OpenTasks("org.dmfs.tasks", "org.dmfs.tasks", 103, "1.1.8.2", PERMISSION_OPENTASKS_READ, PERMISSION_OPENTASKS_WRITE); @@ -48,6 +51,9 @@ class TaskProvider private constructor( companion object { + private val logger + get() = Logger.getLogger(TaskProvider::javaClass.name) + val TASK_PROVIDERS = listOf( ProviderName.OpenTasks, ProviderName.TasksOrg, @@ -87,9 +93,9 @@ class TaskProvider private constructor( if (client != null) return TaskProvider(provider, client) } catch(e: SecurityException) { - Ical4Android.log.log(Level.WARNING, "Not allowed to access task provider", e) + logger.log(Level.WARNING, "Not allowed to access task provider", e) } catch(e: PackageManager.NameNotFoundException) { - Ical4Android.log.warning("Package ${provider.packageName} not installed") + logger.warning("Package ${provider.packageName} not installed") } return null } @@ -114,7 +120,7 @@ class TaskProvider private constructor( val installedVersionCode = PackageInfoCompat.getLongVersionCode(info) if (installedVersionCode < name.minVersionCode) { val exception = ProviderTooOldException(name, installedVersionCode, info.versionName) - Ical4Android.log.log(Level.WARNING, "Task provider too old", exception) + logger.log(Level.WARNING, "Task provider too old", exception) throw exception } } @@ -139,7 +145,7 @@ class TaskProvider private constructor( class ProviderTooOldException( val provider: ProviderName, installedVersionCode: Long, - val installedVersionName: String + val installedVersionName: String? ): Exception("Package ${provider.packageName} has version $installedVersionName ($installedVersionCode), " + "required: ${provider.minVersionName} (${provider.minVersionCode})") diff --git a/src/main/java/at/bitfire/ical4android/UnknownProperty.kt b/lib/src/main/kotlin/at/bitfire/ical4android/UnknownProperty.kt similarity index 85% rename from src/main/java/at/bitfire/ical4android/UnknownProperty.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/UnknownProperty.kt index f1fba041..c1c443e0 100644 --- a/src/main/java/at/bitfire/ical4android/UnknownProperty.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/UnknownProperty.kt @@ -1,13 +1,20 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android import android.content.ContentResolver import net.fortuna.ical4j.data.DefaultParameterFactorySupplier import net.fortuna.ical4j.data.DefaultPropertyFactorySupplier -import net.fortuna.ical4j.model.* +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterBuilder +import net.fortuna.ical4j.model.ParameterFactory +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyBuilder +import net.fortuna.ical4j.model.PropertyFactory import org.json.JSONArray import org.json.JSONObject diff --git a/src/main/java/at/bitfire/ical4android/util/AndroidTimeUtils.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/AndroidTimeUtils.kt similarity index 79% rename from src/main/java/at/bitfire/ical4android/util/AndroidTimeUtils.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/util/AndroidTimeUtils.kt index 2bf3671e..6e52629e 100644 --- a/src/main/java/at/bitfire/ical4android/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/AndroidTimeUtils.kt @@ -1,15 +1,22 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ @file:Suppress("DEPRECATION") package at.bitfire.ical4android.util import android.text.format.Time -import at.bitfire.ical4android.Ical4Android -import net.fortuna.ical4j.model.* +import at.bitfire.ical4android.util.AndroidTimeUtils.androidifyTimeZone +import at.bitfire.ical4android.util.AndroidTimeUtils.storageTzId +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate +import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.TemporalAmountAdapter import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.DateListProperty @@ -21,8 +28,13 @@ import java.text.ParseException import java.text.SimpleDateFormat import java.time.Duration import java.time.Period +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAmount -import java.util.* +import java.util.LinkedList +import java.util.Locale +import java.util.logging.Logger object AndroidTimeUtils { @@ -40,6 +52,9 @@ object AndroidTimeUtils { */ const val RECURRENCE_RULE_SEPARATOR = "\n" + private val logger + get() = Logger.getLogger(javaClass.name) + /** * Ensures that a given [DateProperty] either @@ -131,18 +146,25 @@ object AndroidTimeUtils { * TZID is given) or "yyyymmddThhmmssZ". We don't use the TZID format here because then we're limited * to one time-zone, while an iCalendar may contain multiple EXDATE/RDATE lines with different time zones. * - * @param dates one more more lists of RDATE or EXDATE - * @param allDay whether the event is an all-day event or not + * This method converts the values to the type of [dtStart], if necessary: + * + * - DTSTART (DATE-TIME) and RDATE/EXDATE (DATE) → method converts RDATE/EXDATE to DATE-TIME with same time as DTSTART + * - DTSTART (DATE) and RDATE/EXDATE (DATE-TIME) → method converts RDATE/EXDATE to DATE (just drops time) + * + * @param dates one more more lists of RDATE or EXDATE + * @param dtStart used to determine whether the event is an all-day event or not; also used to + * generate the date-time if the event is not all-day but the exception is * * @return formatted string for Android calendar provider */ - fun recurrenceSetsToAndroidString(dates: List, allDay: Boolean): String { + fun recurrenceSetsToAndroidString(dates: List, dtStart: Date): String { /* rdate/exdate: DATE DATE_TIME all-day store as ...T000000Z cut off time and store as ...T000000Z event with time (undefined) store as ...ThhmmssZ */ val dateFormatUtcMidnight = SimpleDateFormat("yyyyMMdd'T'000000'Z'", Locale.ROOT) val strDates = LinkedList() + val allDay = dtStart !is DateTime // use time zone of first entry for the whole set; null for UTC val tz = @@ -151,31 +173,50 @@ object AndroidTimeUtils { for (dateListProp in dates) { if (dateListProp is RDate && dateListProp.periods.isNotEmpty()) { - Ical4Android.log.warning("RDATE PERIOD not supported, ignoring") + logger.warning("RDATE PERIOD not supported, ignoring") break } when (dateListProp.dates.type) { - Value.DATE_TIME -> { + Value.DATE_TIME -> { // RDATE/EXDATE is DATE-TIME if (tz == null && !dateListProp.dates.isUtc) dateListProp.setUtc(true) else if (tz != null && dateListProp.timeZone != tz) dateListProp.timeZone = tz if (allDay) + // DTSTART is DATE dateListProp.dates.mapTo(strDates) { dateFormatUtcMidnight.format(it) } else + // DTSTART is DATE-TIME strDates.add(dateListProp.value) } - Value.DATE -> - // DATE values have to be converted to DATE-TIME T000000Z for Android - dateListProp.dates.mapTo(strDates) { - dateFormatUtcMidnight.format(it) + Value.DATE -> // RDATE/EXDATE is DATE + if (allDay) { + // DTSTART is DATE; DATE values have to be returned as T000000Z for Android + dateListProp.dates.mapTo(strDates) { date -> + dateFormatUtcMidnight.format(date) + } + } else { + // DTSTART is DATE-TIME; amend DATE-TIME with clock time from dtStart + dateListProp.dates.mapTo(strDates) { date -> + // take time (including time zone) from dtStart and date from date + val dtStartTime = (dtStart as DateTime).toZonedDateTime() + val localDate = date.toLocalDate() + val dtStartTimeUtc = ZonedDateTime.of( + localDate, + dtStartTime.toLocalTime(), + dtStartTime.zone + ).withZoneSameInstant(ZoneOffset.UTC) + + val dateFormatUtc = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + dtStartTimeUtc.format(dateFormatUtc) + } } } } - // format: [tzid;]value1,value2,... + // format expected by Android: [tzid;]value1,value2,... val result = StringBuilder() if (tz != null) result.append(tz.id).append(RECURRENCE_LIST_TZID_SEPARATOR) @@ -259,10 +300,10 @@ object AndroidTimeUtils { for (dateListProp in dates) { if (dateListProp is RDate) if (dateListProp.periods.isNotEmpty()) - Ical4Android.log.warning("RDATE PERIOD not supported, ignoring") + logger.warning("RDATE PERIOD not supported, ignoring") else if (dateListProp is ExDate) if (dateListProp.periods.isNotEmpty()) - Ical4Android.log.warning("EXDATE PERIOD not supported, ignoring") + logger.warning("EXDATE PERIOD not supported, ignoring") for (date in dateListProp.dates) { val dateToUse = diff --git a/src/main/java/at/bitfire/ical4android/util/DateUtils.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/DateUtils.kt similarity index 79% rename from src/main/java/at/bitfire/ical4android/util/DateUtils.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/util/DateUtils.kt index 2962cd3d..f9aac2b7 100644 --- a/src/main/java/at/bitfire/ical4android/util/DateUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/DateUtils.kt @@ -1,11 +1,11 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.util -import at.bitfire.ical4android.Ical4Android -import at.bitfire.ical4android.UsesThreadContextClassLoader import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime @@ -15,6 +15,7 @@ import net.fortuna.ical4j.model.component.VTimeZone import net.fortuna.ical4j.model.property.DateProperty import java.io.StringReader import java.time.ZoneId +import java.util.logging.Logger /** * Date/time utilities @@ -24,17 +25,15 @@ import java.time.ZoneId */ object DateUtils { + private val logger + get() = Logger.getLogger(javaClass.name) + /** * Global ical4j time zone registry used for event/task processing. Do not * modify this registry or its entries! */ - @UsesThreadContextClassLoader private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() - init { - Ical4Android.checkThreadContextClassLoader() - } - // time zones @@ -66,7 +65,7 @@ object DateUtils { for (availableTZ in availableTZs) if (availableTZ.contains(tzID) || tzID.contains(availableTZ)) { result = availableTZ - Ical4Android.log.warning("Couldn't find system time zone \"$tzID\", assuming $result") + logger.warning("Couldn't find system time zone \"$tzID\", assuming $result") break } } @@ -93,8 +92,6 @@ object DateUtils { } } - @Suppress("DEPRECATION") - @UsesThreadContextClassLoader /** * Loads a time zone from the ical4j time zone registry (which contains the * VTIMEZONE definitions). @@ -123,20 +120,20 @@ object DateUtils { fun isDateTime(date: DateProperty?) = date != null && date.date is DateTime /** - * Parses a VTIMEZONE definition to a VTimeZone object. - * @param timezoneDef VTIMEZONE definition - * @return parsed VTimeZone - * @throws IllegalArgumentException when the timezone definition can't be parsed + * Parses an iCalendar that only contains a `VTIMEZONE` definition to a VTimeZone object. + * + * @param timezoneDef iCalendar with only a `VTIMEZONE` definition + * + * @return parsed [VTimeZone], or `null` when the timezone definition can't be parsed */ - @UsesThreadContextClassLoader - fun parseVTimeZone(timezoneDef: String): VTimeZone { - Ical4Android.checkThreadContextClassLoader() + fun parseVTimeZone(timezoneDef: String): VTimeZone? { val builder = CalendarBuilder(tzRegistry) try { val cal = builder.build(StringReader(timezoneDef)) return cal.getComponent(VTimeZone.VTIMEZONE) as VTimeZone - } catch (e: Exception) { - throw IllegalArgumentException("Couldn't parse timezone definition") + } catch (_: Exception) { + // Couldn't parse timezone definition + return null } } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/util/MiscUtils.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/MiscUtils.kt new file mode 100644 index 00000000..00b7f941 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/MiscUtils.kt @@ -0,0 +1,67 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android.util + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.Cursor +import android.database.DatabaseUtils +import android.net.Uri +import android.os.Build +import android.provider.CalendarContract + +object MiscUtils { + + // various extension methods + + fun ContentProviderClient.closeCompat() { + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + close() + else + release() + } + + /** + * Removes blank (empty or only white-space) [String] values from [ContentValues]. + * + * @return the modified object (which is the same object as passed in; for chaining) + */ + fun ContentValues.removeBlankStrings(): ContentValues { + val iter = keySet().iterator() + while (iter.hasNext()) { + val obj = this[iter.next()] + if (obj is CharSequence && obj.isBlank()) + iter.remove() + } + return this + } + + /** + * Returns the entire contents of the current row as a [ContentValues] object. + * + * @param removeBlankRows whether rows with blank values should be removed + * @return entire contents of the current row + */ + fun Cursor.toValues(removeBlankRows: Boolean = false): ContentValues { + val values = ContentValues(columnCount) + DatabaseUtils.cursorRowToContentValues(this, values) + + if (removeBlankRows) + values.removeBlankStrings() + + return values + } + + fun Uri.asSyncAdapter(account: Account): Uri = buildUpon() + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, account.name) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, account.type) + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .build() + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/util/TimeApiExtensions.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt similarity index 92% rename from src/main/java/at/bitfire/ical4android/util/TimeApiExtensions.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt index 9ff5529b..88cc75c1 100644 --- a/src/main/java/at/bitfire/ical4android/util/TimeApiExtensions.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt @@ -1,15 +1,25 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.util import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.util.TimeZones -import java.time.* +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.Period +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime import java.time.temporal.TemporalAmount -import java.util.* +import java.util.Calendar +import java.util.TimeZone object TimeApiExtensions { diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/validation/EventValidator.kt b/lib/src/main/kotlin/at/bitfire/ical4android/validation/EventValidator.kt new file mode 100644 index 00000000..ede64f39 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/validation/EventValidator.kt @@ -0,0 +1,214 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android.validation + +import androidx.annotation.VisibleForTesting +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate +import at.bitfire.ical4android.util.TimeApiExtensions.toZoneIdCompat +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.util.TimeZones +import java.time.LocalTime +import java.time.ZonedDateTime +import java.util.Calendar +import java.util.TimeZone +import java.util.logging.Logger + +/** + * Validates events and tries to repair broken events, since sometimes CalendarStorage or servers + * respond with invalid event definitions. + * + * This class should not throw exceptions, but try to repair as much as possible instead. + * + * This class is applied + * + * - once to every event after completely reading an iCalendar, and + * - to every event when writing an iCalendar. + */ +object EventValidator { + + private val logger + get() = Logger.getLogger(javaClass.name) + + /** + * Searches for some invalid conditions and fixes them. + * + * @param event event to repair (including its exceptions) – may be modified! + */ + fun repair(event: Event) { + val dtStart = correctStartAndEndTime(event) + sameTypeForDtStartAndRruleUntil(dtStart, event.rRules) + removeRRulesWithUntilBeforeDtStart(dtStart, event.rRules) + removeRecurrenceOfExceptions(event.exceptions) + } + + + /** + * Makes sure that event has a start time and that it's before the end time. + * If the event doesn't have start time, + * + * 1. the end time is used as start time, if available, + * 2. otherwise the current time is used as start time. + * + * If the event has an end time and it's before the start time, the end time is removed. + * + * @return the (potentially corrected) start time + */ + @VisibleForTesting + internal fun correctStartAndEndTime(e: Event): DtStart { + // make sure that event has a start time + var dtStart: DtStart? = e.dtStart + if (dtStart == null) { + dtStart = + e.dtEnd?.let { + DtStart(it.date) + } ?: DtStart(DateTime(/* current time */)) + e.dtStart = dtStart + } + + // validate end time + e.dtEnd?.let { dtEnd -> + if (dtStart.date > dtEnd.date) { + logger.warning("DTSTART after DTEND; removing DTEND") + e.dtEnd = null + } + } + + return dtStart + } + + /** + * Tries to make the value type of UNTIL and DTSTART the same (both DATE or DATETIME). + */ + @VisibleForTesting + internal fun sameTypeForDtStartAndRruleUntil(dtStart: DtStart, rRules: MutableList) { + if (DateUtils.isDate(dtStart)) { + // DTSTART is a DATE + val newRRules = mutableListOf() + val rRuleIterator = rRules.iterator() + while (rRuleIterator.hasNext()) { + val rRule = rRuleIterator.next() + rRule.recur.until?.let { until -> + if (until is DateTime) { + logger.warning("DTSTART has DATE, but UNTIL has DATETIME; making UNTIL have DATE only") + + val newUntil = until.toLocalDate().toIcal4jDate() + + // remove current RRULE and remember new one to be added + val newRRule = RRule(Recur.Builder(rRule.recur) + .until(newUntil) + .build()) + logger.info("New $newRRule (was ${rRule.toString().trim()})") + newRRules += newRRule + rRuleIterator.remove() + } + } + } + // add repaired RRULEs + rRules += newRRules + + } else if (DateUtils.isDateTime(dtStart)) { + // DTSTART is a DATE-TIME + val newRRules = mutableListOf() + val rRuleIterator = rRules.iterator() + while (rRuleIterator.hasNext()) { + val rRule = rRuleIterator.next() + rRule.recur.until?.let { until -> + if (until !is DateTime) { + logger.warning("DTSTART has DATETIME, but UNTIL has DATE; copying time from DTSTART to UNTIL") + val dtStartTimeZone = if (dtStart.timeZone != null) + dtStart.timeZone + else if (dtStart.isUtc) + TimeZones.getUtcTimeZone() + else /* floating time */ + TimeZone.getDefault() + + val dtStartCal = Calendar.getInstance(dtStartTimeZone).apply { + time = dtStart.date + } + val dtStartTime = LocalTime.of( + dtStartCal.get(Calendar.HOUR_OF_DAY), + dtStartCal.get(Calendar.MINUTE), + dtStartCal.get(Calendar.SECOND) + ) + + val newUntil = ZonedDateTime.of( + until.toLocalDate(), // date from until + dtStartTime, // time from dtStart + dtStartTimeZone.toZoneIdCompat() + ) + + // Android requires UNTIL in UTC as defined in RFC 2445. + // https://android.googlesource.com/platform/frameworks/opt/calendar/+/refs/tags/android-12.1.0_r27/src/com/android/calendarcommon2/RecurrenceProcessor.java#93 + val newUntilUTC = DateTime(true).apply { + time = newUntil.toInstant().toEpochMilli() + } + + // remove current RRULE and remember new one to be added + val newRRule = RRule(Recur.Builder(rRule.recur) + .until(newUntilUTC) + .build()) + logger.info("New $newRRule (was ${rRule.toString().trim()})") + newRRules += newRRule + rRuleIterator.remove() + } + } + } + // add repaired RRULEs + rRules += newRRules + } + } + + + /** + * Removes all recurrence information of exceptions of (potentially recurring) events. This is: + * `RRULE`, `RDATE` and `EXDATE`. + * Note: This repair step needs to be applied after all exceptions have been found. + * + * @param exceptions exceptions of an event + */ + @VisibleForTesting + internal fun removeRecurrenceOfExceptions(exceptions: List) { + for (exception in exceptions) { + // Drop all RRULEs, RDATEs, EXDATEs for the exception + exception.rRules.clear() + exception.rDates.clear() + exception.exDates.clear() + } + } + + + /** + * Will remove the RRULES of an event where UNTIL lies before DTSTART + */ + @VisibleForTesting + internal fun removeRRulesWithUntilBeforeDtStart(dtStart: DtStart, rRules: MutableList) { + val iter = rRules.iterator() + while (iter.hasNext()) { + val rRule = iter.next() + + // drop invalid RRULEs + if (hasUntilBeforeDtStart(dtStart, rRule)) + iter.remove() + } + } + + /** + * Checks whether UNTIL of an RRULE lies before DTSTART + */ + @VisibleForTesting + internal fun hasUntilBeforeDtStart(dtStart: DtStart, rRule: RRule): Boolean { + val until = rRule.recur.until ?: return false + return until < dtStart.date + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt new file mode 100644 index 00000000..bcb4d0fb --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt @@ -0,0 +1,42 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android.validation + +/** + * Fixes durations with day offsets with the 'T' prefix. + * See also https://github.com/bitfireAT/ical4android/issues/77 + */ +object FixInvalidDayOffsetPreprocessor : StreamPreprocessor() { + + override fun regexpForProblem() = Regex( + // Examples: + // TRIGGER:-P2DT + // TRIGGER:-PT2D + // REFRESH-INTERVAL;VALUE=DURATION:-PT1D + "(?:^|^(?:DURATION|REFRESH-INTERVAL|RELATED-TO|TRIGGER);VALUE=)(?:DURATION|TRIGGER):(-?P((T-?\\d+D)|(-?\\d+DT)))$", + setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE) + ) + + override fun fixString(original: String): String { + var iCal: String = original + + // Find all instances matching the defined expression + val found = regexpForProblem().findAll(iCal).toList() + + // ... and repair them. Use reversed order so that already replaced occurrences don't interfere with the following matches. + for (match in found.reversed()) { + match.groups[1]?.let { duration -> // first capturing group is the duration value, for instance: "-PT1D" + val fixed = duration.value // fixed is then for instance: "-P1D" + .replace("PT", "P") + .replace("DT", "D") + iCal = iCal.replaceRange(duration.range, fixed) + } + } + return iCal + } + +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt similarity index 65% rename from src/main/java/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt index b9ef7c6e..3012ce60 100644 --- a/src/main/java/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessor.kt @@ -1,12 +1,14 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.validation -import at.bitfire.ical4android.Ical4Android import at.bitfire.ical4android.validation.FixInvalidUtcOffsetPreprocessor.TZOFFSET_REGEXP import java.util.logging.Level +import java.util.logging.Logger /** @@ -17,6 +19,9 @@ import java.util.logging.Level */ object FixInvalidUtcOffsetPreprocessor: StreamPreprocessor() { + private val logger + get() = Logger.getLogger(javaClass.name) + private val TZOFFSET_REGEXP = Regex("^(TZOFFSET(FROM|TO):[+\\-]?)((18|19|[2-6]\\d)\\d\\d)$", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) @@ -24,7 +29,7 @@ object FixInvalidUtcOffsetPreprocessor: StreamPreprocessor() { override fun fixString(original: String) = original.replace(TZOFFSET_REGEXP) { - Ical4Android.log.log(Level.FINE, "Applying Synology WebDAV fix to invalid utc-offset", it.value) + logger.log(Level.FINE, "Applying Synology WebDAV fix to invalid utc-offset", it.value) "${it.groupValues[1]}00${it.groupValues[3]}" } diff --git a/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/ical4android/validation/ICalPreprocessor.kt similarity index 82% rename from src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/validation/ICalPreprocessor.kt index f2cb6a1b..de905d48 100644 --- a/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/validation/ICalPreprocessor.kt @@ -1,10 +1,11 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.validation -import at.bitfire.ical4android.Ical4Android import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule @@ -12,8 +13,8 @@ import net.fortuna.ical4j.transform.rfc5545.DateListPropertyRule import net.fortuna.ical4j.transform.rfc5545.DatePropertyRule import net.fortuna.ical4j.transform.rfc5545.Rfc5545PropertyRule import java.io.Reader -import java.util.* import java.util.logging.Level +import java.util.logging.Logger /** * Applies some rules to increase compatibility of parsed (incoming) iCalendars: @@ -29,10 +30,10 @@ object ICalPreprocessor { CreatedPropertyRule(), // make sure CREATED is UTC DatePropertyRule(), // These two rules also replace VTIMEZONEs of the iCalendar ... - DateListPropertyRule(), // ... by the ical4j VTIMEZONE with the same TZID! + DateListPropertyRule() // ... by the ical4j VTIMEZONE with the same TZID! ) - val streamPreprocessors = arrayOf( + private val streamPreprocessors = arrayOf( FixInvalidUtcOffsetPreprocessor, // fix things like TZOFFSET(FROM,TO):+5730 FixInvalidDayOffsetPreprocessor // fix things like DURATION:PT2D ) @@ -72,7 +73,7 @@ object ICalPreprocessor { (it as Rfc5545PropertyRule).applyTo(property) val afterStr = property.toString() if (beforeStr != afterStr) - Ical4Android.log.log(Level.FINER, "$beforeStr -> $afterStr") + Logger.getLogger(javaClass.name).log(Level.FINER, "$beforeStr -> $afterStr") } } diff --git a/src/main/java/at/bitfire/ical4android/validation/StreamPreprocessor.kt b/lib/src/main/kotlin/at/bitfire/ical4android/validation/StreamPreprocessor.kt similarity index 67% rename from src/main/java/at/bitfire/ical4android/validation/StreamPreprocessor.kt rename to lib/src/main/kotlin/at/bitfire/ical4android/validation/StreamPreprocessor.kt index 68f0aaa3..2b35e641 100644 --- a/src/main/java/at/bitfire/ical4android/validation/StreamPreprocessor.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/validation/StreamPreprocessor.kt @@ -1,19 +1,26 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.validation -import org.apache.commons.io.IOUtils import java.io.IOException import java.io.Reader import java.io.StringReader -import java.util.* +import java.util.Scanner abstract class StreamPreprocessor { abstract fun regexpForProblem(): Regex? + /** + * Fixes an iCalendar string. + * + * @param original The complete iCalendar string + * @return The complete iCalendar string, but fixed + */ abstract fun fixString(original: String): String fun preprocess(reader: Reader): Reader { @@ -22,7 +29,7 @@ abstract class StreamPreprocessor { val resetSupported = try { reader.reset() true - } catch(e: IOException) { + } catch(_: IOException) { false } @@ -31,11 +38,11 @@ abstract class StreamPreprocessor { // reset is supported, no need to copy the whole stream to another String (unless we have to fix the TZOFFSET) if (regex == null || Scanner(reader).findWithinHorizon(regex.toPattern(), 0) != null) { reader.reset() - result = fixString(IOUtils.toString(reader)) + result = fixString(reader.readText()) } } else // reset not supported, always generate a new String that will be returned - result = fixString(IOUtils.toString(reader)) + result = fixString(reader.readText()) if (result != null) // modified or reset not supported, return new stream diff --git a/src/main/java/at/techbee/jtx/JtxContract.kt b/lib/src/main/kotlin/at/techbee/jtx/JtxContract.kt similarity index 96% rename from src/main/java/at/techbee/jtx/JtxContract.kt rename to lib/src/main/kotlin/at/techbee/jtx/JtxContract.kt index 66ede8b2..31ea075f 100644 --- a/src/main/java/at/techbee/jtx/JtxContract.kt +++ b/lib/src/main/kotlin/at/techbee/jtx/JtxContract.kt @@ -11,7 +11,6 @@ package at.techbee.jtx import android.accounts.Account import android.net.Uri import android.provider.BaseColumns -import at.bitfire.ical4android.Ical4Android import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList @@ -19,10 +18,15 @@ import net.fortuna.ical4j.model.parameter.XParameter import net.fortuna.ical4j.model.property.XProperty import org.json.JSONObject import java.util.logging.Level +import java.util.logging.Logger + @Suppress("unused") object JtxContract { + private val logger + get() = Logger.getLogger(javaClass.name) + /** * URI parameter to signal that the caller is a sync adapter. */ @@ -42,7 +46,7 @@ object JtxContract { const val AUTHORITY = "at.techbee.jtx.provider" /** The version of this SyncContentProviderContract */ - const val VERSION = 2 + const val VERSION = 8 /** Constructs an Uri for the Jtx Sync Adapter with the given Account * @param [account] The account that should be appended to the Base Uri @@ -100,7 +104,7 @@ object JtxContract { } } } catch (e: NullPointerException) { - Ical4Android.log.log(Level.WARNING, "Error parsing x-property-list $string", e) + logger.log(Level.WARNING, "Error parsing x-property-list $string", e) } return propertyList } @@ -163,7 +167,7 @@ object JtxContract { try { longList.add(it.toLong()) } catch (e: NumberFormatException) { - Ical4Android.log.log(Level.WARNING, "String could not be cast to Long ($it)") + logger.log(Level.WARNING, "String could not be cast to Long ($it)") return@forEach } } @@ -191,8 +195,8 @@ object JtxContract { const val ID = BaseColumns._ID /** The column for the module. - * This is an internal differentiation for JOURNAL, NOTE and TODO as provided in the enum [Module]. - * The Module does not need to be set. On import it will be derived from the component from the [Component] (Todo or Journal/Note) and if a + * This is an internal differentiation for JOURNAL, NOTE and T0DO as provided in the enum [Module]. + * The Module does not need to be set. On import it will be derived from the component from the [Component] (T0do or Journal/Note) and if a * dtstart is present or not (Journal or Note). If the module was set, it might be overwritten on import. In this sense also * unknown values are overwritten. * Use e.g. Module.JOURNAL.name for a correct String value in this field. @@ -272,6 +276,20 @@ object JtxContract { */ const val STATUS = "status" + /** + * Purpose: To specify the filename of the attachment. + * This is an X-PROPERTY that should be addressed as "X-LABEL" + * Type: [String] + */ + const val EXTENDED_STATUS = "xstatus" + + /** + * Purpose: Defines the radius for a geofence in meters + * This is put into an extended property in the iCalendar-file + * Type: [String] + */ + const val GEOFENCE_RADIUS = "geofenceRadius" + /** * Purpose: This property defines the access classification for a calendar component. * The possible values of a status are defined in the enum [Classification]. @@ -436,6 +454,17 @@ object JtxContract { */ const val RECURID = "recurid" + /** + * Purpose: This property is used in conjunction with the "UID" and + * "SEQUENCE" properties to identify a specific instance of a + * recurring "VEVENT", "VTODO", or "VJOURNAL" calendar component. + * The property value is the original value of the "DTSTART" property + * of the recurrence instance, ie. a DATE or DATETIME value e.g. "20211101T160000". + * Must be null for non-recurring and original events from which recurring events are derived. + * Type: [String?] + */ + const val RECURID_TIMEZONE = "recuridtimezone" + /** * Stores the reference to the original event from which the recurring event was derived. * This value is NULL for the orignal event or if the event is not recurring @@ -489,7 +518,7 @@ object JtxContract { const val SEQUENCE = "sequence" /** - * Purpose: This property specifies a color used for displaying the calendar, event, todo, or journal data. + * Purpose: This property specifies a color used for displaying the calendar, event, t0do, or journal data. * See [https://tools.ietf.org/html/rfc7986#section-5.9]. * The color is represented as Int-value as described in [https://developer.android.com/reference/android/graphics/Color#color-ints] * Type: [Int] @@ -1119,11 +1148,17 @@ object JtxContract { const val DESCRIPTION = "description" /** - * Purpose: This column/property defines the owner of the collection. + * Purpose: This column/property defines the URL of the owner of the collection. * Type: [String] */ const val OWNER = "owner" + /** + * Purpose: This column/property defines the display name of the owner of the collection. + * Type: [String] + */ + const val OWNER_DISPLAYNAME = "ownerdisplayname" + /** * Purpose: This column/property defines the color of the collection items. * This color can also be overwritten by the color in an ICalObject. @@ -1174,6 +1209,18 @@ object JtxContract { */ const val READONLY = "readonly" + /** + * Purpose: This column/property defines the date/time of the last sync + * Type: [Long] + */ + const val LAST_SYNC = "lastsync" + + /** + * Purpose: This column/property stores a sync_id for the given collection + * See https://github.com/TechbeeAT/jtxBoard/issues/1635 + * Type: [Long] + */ + const val SYNC_ID = "sync_id" } diff --git a/src/main/resources/ical4j.properties b/lib/src/main/resources/ical4j.properties similarity index 84% rename from src/main/resources/ical4j.properties rename to lib/src/main/resources/ical4j.properties index 32a2d3d8..edc3d429 100644 --- a/src/main/resources/ical4j.properties +++ b/lib/src/main/resources/ical4j.properties @@ -1,4 +1,5 @@ net.fortuna.ical4j.timezone.cache.impl=net.fortuna.ical4j.util.MapTimeZoneCache +net.fortuna.ical4j.timezone.offset.negative_dst_supported=true net.fortuna.ical4j.timezone.registry=at.bitfire.ical4android.AndroidCompatTimeZoneRegistry$Factory net.fortuna.ical4j.timezone.update.enabled=false ical4j.unfolding.relaxed=true diff --git a/src/main/resources/tz.alias b/lib/src/main/resources/tz.alias similarity index 100% rename from src/main/resources/tz.alias rename to lib/src/main/resources/tz.alias diff --git a/src/test/README.txt b/lib/src/test/README.txt similarity index 100% rename from src/test/README.txt rename to lib/src/test/README.txt diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/Ical4jServiceLoaderTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/Ical4jServiceLoaderTest.kt new file mode 100644 index 00000000..0c6df32f --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/ical4android/Ical4jServiceLoaderTest.kt @@ -0,0 +1,37 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import net.fortuna.ical4j.data.CalendarBuilder +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.component.VEvent +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.StringReader + +class Ical4jServiceLoaderTest { + + @Test + fun Ical4j_ServiceLoader_DoesntNeedContextClassLoader() { + Thread.currentThread().contextClassLoader = null + + val iCal = "BEGIN:VCALENDAR\n" + + "PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VEVENT\n" + + "UID:uid1@example.com\n" + + "DTSTART:19960918T143000Z\n" + + "DTEND:19960920T220000Z\n" + + "SUMMARY:Networld+Interop Conference\n" + + "END:VEVENT\n" + + "END:VCALENDAR\n" + val result = CalendarBuilder().build(StringReader(iCal)) + val vEvent = result.getComponent(Component.VEVENT) + assertEquals("Networld+Interop Conference", vEvent.summary.value) + } + +} \ No newline at end of file diff --git a/src/androidTest/java/at/bitfire/ical4android/validation/EventValidatorTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/validation/EventValidatorTest.kt similarity index 50% rename from src/androidTest/java/at/bitfire/ical4android/validation/EventValidatorTest.kt rename to lib/src/test/kotlin/at/bitfire/ical4android/validation/EventValidatorTest.kt index fa3d9594..b7f5eb54 100644 --- a/src/androidTest/java/at/bitfire/ical4android/validation/EventValidatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/validation/EventValidatorTest.kt @@ -1,51 +1,81 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.validation import at.bitfire.ical4android.Event -import at.bitfire.ical4android.InvalidCalendarException -import net.fortuna.ical4j.model.* +import at.bitfire.ical4android.util.DateUtils +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistry +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VTimeZone +import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule -import org.junit.Assert.* -import org.junit.Assume +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.TzId +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.Test import java.io.StringReader class EventValidatorTest { companion object { - val tzReg = TimeZoneRegistryFactory.getInstance().createRegistry() + val tzReg: TimeZoneRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() } // DTSTART and DTEND - @Test(expected = InvalidCalendarException::class) - fun testEnsureCorrectStartAndEndTime_noDtStart_DateTime() { + @Test + fun testCorrectStartAndEndTime_NoDtStart_EndDateTime() { val event = Event().apply { + // no dtStart dtEnd = DtEnd(DateTime("20000105T000000")) // DATETIME + } + EventValidator.correctStartAndEndTime(event) + assertEquals(event.dtEnd!!.date, event.dtStart!!.date) + } + + @Test + fun testCorrectStartAndEndTime_NoDtStart_EndDate() { + val event = Event().apply { // no dtStart + dtEnd = DtEnd(Date("20000105")) // DATE } EventValidator.correctStartAndEndTime(event) + assertEquals(event.dtEnd!!.date, event.dtStart!!.date) } - @Test(expected = InvalidCalendarException::class) - fun testEnsureCorrectStartAndEndTime_noDtStart_Date() { - Event.eventsFromReader(StringReader( - "BEGIN:VCALENDAR\n" + - "BEGIN:VEVENT\n" + - "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + - "DTEND;VALUE=DATE:20211116\n" + // DATE - "END:VEVENT\n" + - "END:VCALENDAR")).first() + @Test + fun testCorrectStartAndEndTime_NoDtStart_NoDtEnd() { + val event = Event(/* no dtStart, no dtEnd */) + + val time = System.currentTimeMillis() + EventValidator.correctStartAndEndTime(event) + + assertTrue(event.dtStart!!.date.time in (time-1000)..<(time+1000)) // within 2 seconds + assertNull(event.dtEnd) } @Test - fun testEnsureCorrectStartAndEndTime_dtEndBeforeDtStart() { + fun testCorrectStartAndEndTime_DtEndBeforeDtStart() { val event = Event().apply { dtStart = DtStart(DateTime("20000105T001100")) // DATETIME dtEnd = DtEnd(DateTime("20000105T000000")) // DATETIME @@ -188,19 +218,18 @@ class EventValidatorTest { EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) assertEquals("FREQ=MONTHLY;UNTIL=20211214T001100Z", event.rRules.joinToString()) - Assume.assumeTrue(TimeZone.getDefault().id == "Europe/Vienna") val event2 = Event.eventsFromReader( StringReader( "BEGIN:VCALENDAR\n" + "BEGIN:VEVENT\n" + "UID:381fb26b-2da5-4dd2-94d7-2e0874128aa7\n" + - "DTSTART;VALUE=DATETIME:20080214T001100\n" + // DATETIME (no timezone) - "RRULE:FREQ=YEARLY;UNTIL=20110214;BYMONTHDAY=15\n" + // DATE + "DTSTART;VALUE=DATETIME:20110605T001100Z\n" + // DATETIME (UTC) + "RRULE:FREQ=YEARLY;UNTIL=20211214;BYMONTHDAY=15\n" + // DATE "END:VEVENT\n" + "END:VCALENDAR" ) ).first() - assertEquals("FREQ=YEARLY;UNTIL=20110213T231100Z;BYMONTHDAY=15", event2.rRules.joinToString()) + assertEquals("FREQ=YEARLY;UNTIL=20211214T001100Z;BYMONTHDAY=15", event2.rRules.joinToString()) } @@ -210,7 +239,8 @@ class EventValidatorTest { fun testHasUntilBeforeDtStart_DtStartTime_RRuleNoUntil() { assertFalse( EventValidator.hasUntilBeforeDtStart( - DtStart(DateTime("20220531T010203")), RRule()) + DtStart(DateTime("20220531T010203")), RRule() + ) ) } @@ -218,46 +248,70 @@ class EventValidatorTest { @Test fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_UTC() { assertTrue( - EventValidator.hasUntilBeforeDtStart(DtStart("20220912", tzReg.getTimeZone("UTC")), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220911T235959Z")) - .build()))) + EventValidator.hasUntilBeforeDtStart( + DtStart("20220912", tzReg.getTimeZone("UTC")), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959Z")) + .build() + ) + ) + ) } @Test fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_noTimezone() { assertTrue( - EventValidator.hasUntilBeforeDtStart(DtStart("20220912"), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220911T235959")) - .build()))) + EventValidator.hasUntilBeforeDtStart( + DtStart("20220912"), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959")) + .build() + ) + ) + ) } @Test fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_withTimezone() { assertTrue( - EventValidator.hasUntilBeforeDtStart(DtStart("20220912", tzReg.getTimeZone("America/New_York")), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220911T235959", tzReg.getTimeZone("America/New_York"))) - .build()))) + EventValidator.hasUntilBeforeDtStart( + DtStart("20220912", tzReg.getTimeZone("America/New_York")), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959", tzReg.getTimeZone("America/New_York"))) + .build() + ) + ) + ) } @Test fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_DateBeforeDtStart() { assertTrue( - EventValidator.hasUntilBeforeDtStart(DtStart("20220531"), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220530T000000")) - .build()))) + EventValidator.hasUntilBeforeDtStart( + DtStart("20220531"), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220530T000000")) + .build() + ) + ) + ) } @Test fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeAfterDtStart() { assertFalse( - EventValidator.hasUntilBeforeDtStart(DtStart("20200912"), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220912T000001Z")) - .build())) + EventValidator.hasUntilBeforeDtStart( + DtStart("20200912"), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220912T000001Z")) + .build() + ) + ) ) } @@ -265,43 +319,167 @@ class EventValidatorTest { @Test fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_DateBeforeDtStart() { assertTrue( - EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(Date("20220530")) - .build())) + EventValidator.hasUntilBeforeDtStart( + DtStart(DateTime("20220531T010203")), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(Date("20220530")) + .build() + ) + ) ) } @Test fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeBeforeDtStart() { assertTrue( - EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220531T010202")) - .build())) + EventValidator.hasUntilBeforeDtStart( + DtStart(DateTime("20220531T010203")), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010202")) + .build() + ) + ) ) } @Test fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeAtDtStart() { assertFalse( - EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220531T010203")) - .build())) + EventValidator.hasUntilBeforeDtStart( + DtStart(DateTime("20220531T010203")), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010203")) + .build() + ) + ) ) } @Test fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeAfterDtStart() { assertFalse( - EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() - .frequency(Recur.Frequency.DAILY) - .until(DateTime("20220531T010204")) - .build())) + EventValidator.hasUntilBeforeDtStart( + DtStart(DateTime("20220531T010203")), RRule( + Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010204")) + .build() + ) + ) ) } + @Test + fun testRemoveRecurrencesOfRecurringWithExceptions() { + // Test manually created event + val tz = DateUtils.ical4jTimeZone("Europe/Paris") + val manualEvent = Event().apply { + dtStart = DtStart("20240219T130000", tz) + dtEnd = DtEnd("20240219T140000", tz) + summary = "recurring event" + rRules.add(RRule(Recur.Builder() // Should keep this RRULE + .frequency(Recur.Frequency.DAILY) + .interval(1) + .count(5) + .build())) + sequence = 0 + uid = "76c08fb1-99a3-41cf-b482-2d3b06648814" + exceptions.add(Event().apply { + dtStart = DtStart("20240221T110000", tz) + dtEnd = DtEnd("20240221T120000", tz) + recurrenceId = RecurrenceId("20240221T130000", tz) + sequence = 0 + summary = "exception of recurring event" + rRules.addAll(listOf( + RRule(Recur.Builder() // but remove this one + .frequency(Recur.Frequency.DAILY) + .count(6) + .interval(2) + .build()), + RRule(Recur.Builder() // and this one + .frequency(Recur.Frequency.DAILY) + .count(6) + .interval(2) + .build()) + )) + rDates.addAll(listOf( + RDate(DateList(Value("19970714T123000Z"))), + RDate( + DateList( + Value("19960403T020000Z"), + TimeZone( + VTimeZone( + PropertyList(1).apply { + add(TzId("US-EASTERN")) + } + ) + ) + ) + ) + )) + exDates.addAll(listOf( + ExDate(DateList(Value("19970714T123000Z"))), + ExDate( + DateList( + Value("19960403T020000Z"), + TimeZone( + VTimeZone( + PropertyList(1).apply { + add(TzId("US-EASTERN")) + } + ) + ) + ) + ) + )) + uid = "76c08fb1-99a3-41cf-b482-2d3b06648814" + }) + } + assertTrue(manualEvent.rRules.size == 1) + assertTrue(manualEvent.exceptions.first().rRules.size == 2) + assertTrue(manualEvent.exceptions.first().rDates.size == 2) + assertTrue(manualEvent.exceptions.first().exDates.size == 2) + EventValidator.removeRecurrenceOfExceptions(manualEvent.exceptions) // Repair the manually created event + assertTrue(manualEvent.rRules.size == 1) + assertTrue(manualEvent.exceptions.first().rRules.isEmpty()) + assertTrue(manualEvent.exceptions.first().rDates.isEmpty()) + assertTrue(manualEvent.exceptions.first().exDates.isEmpty()) + + // Test event from reader, the reader will repair the event itself + val eventFromReader = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "DTSTAMP:20240215T102755Z\n" + + "SUMMARY:recurring event\n" + + "DTSTART;TZID=Europe/Paris:20240219T130000\n" + + "DTEND;TZID=Europe/Paris:20240219T140000\n" + + "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5\n" + // Should keep this RRULE + "UID:76c08fb1-99a3-41cf-b482-2d3b06648814\n" + + "END:VEVENT\n" + + + // Exception for the recurring event above + "BEGIN:VEVENT\n" + + "DTSTAMP:20240215T102908Z\n" + + "RECURRENCE-ID;TZID=Europe/Paris:20240221T130000\n" + + "SUMMARY:exception of recurring event\n" + + "RRULE:FREQ=DAILY;COUNT=6;INTERVAL=2\n" + // but remove this one + "RRULE:FREQ=DAILY;COUNT=6;INTERVAL=2\n" + // and this one + "EXDATE;TZID=Europe/Paris:20240704T193000\n" + // also this + "RDATE;TZID=US-EASTERN:19970714T083000\n" + // and this + "DTSTART;TZID=Europe/Paris:20240221T110000\n" + + "DTEND;TZID=Europe/Paris:20240221T120000\n" + + "UID:76c08fb1-99a3-41cf-b482-2d3b06648814\n" + + "END:VEVENT\n" + + "END:VCALENDAR" + )).first() + assertTrue(eventFromReader.rRules.size == 1) + assertTrue(eventFromReader.exceptions.first().rRules.isEmpty()) + assertTrue(eventFromReader.exceptions.first().rDates.isEmpty()) + assertTrue(eventFromReader.exceptions.first().exDates.isEmpty()) + } @Test fun testRemoveRRulesWithUntilBeforeDtStart() { diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt new file mode 100644 index 00000000..87f7dc8d --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt @@ -0,0 +1,114 @@ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android.validation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Duration + +class FixInvalidDayOffsetPreprocessorTest { + + /** + * Calls [FixInvalidDayOffsetPreprocessor.fixString] and asserts the result is equal to [expected]. + * + * @param expected The expected result + * @param testValue The value to test + * @param parseDuration If `true`, [Duration.parse] is called on the fixed value to make sure it's a valid duration + */ + private fun assertFixedEquals(expected: String, testValue: String, parseDuration: Boolean = true) { + // Fix the duration string + val fixed = FixInvalidDayOffsetPreprocessor.fixString(testValue) + + // Test the duration can now be parsed + if (parseDuration) + for (line in fixed.split('\n')) { + val duration = line.substring(line.indexOf(':') + 1) + Duration.parse(duration) + } + + // Assert + assertEquals(expected, fixed) + } + + @Test + fun test_FixString_NoOccurrence() { + assertEquals( + "Some String", + FixInvalidDayOffsetPreprocessor.fixString("Some String"), + ) + } + + @Test + fun test_FixString_SucceedsAsValueOnCorrectProperties() { + // By RFC 5545 the only properties allowed to hold DURATION as a VALUE are: + // DURATION, REFRESH, RELATED, TRIGGER + assertFixedEquals("DURATION;VALUE=DURATION:P1D", "DURATION;VALUE=DURATION:PT1D") + assertFixedEquals("REFRESH-INTERVAL;VALUE=DURATION:P1D", "REFRESH-INTERVAL;VALUE=DURATION:PT1D") + assertFixedEquals("RELATED-TO;VALUE=DURATION:P1D", "RELATED-TO;VALUE=DURATION:PT1D") + assertFixedEquals("TRIGGER;VALUE=DURATION:P1D", "TRIGGER;VALUE=DURATION:PT1D") + } + + @Test + fun test_FixString_FailsAsValueOnWrongProperty() { + // The update from RFC 2445 to RFC 5545 disallows using DURATION as a VALUE in FREEBUSY + assertFixedEquals("FREEBUSY;VALUE=DURATION:PT1D", "FREEBUSY;VALUE=DURATION:PT1D", parseDuration = false) + } + + @Test + fun test_FixString_FailsIfNotAtStartOfLine() { + assertFixedEquals("xxDURATION;VALUE=DURATION:PT1D", "xxDURATION;VALUE=DURATION:PT1D", parseDuration = false) + } + + @Test + fun test_FixString_DayOffsetFrom_Invalid() { + assertFixedEquals("DURATION:-P1D", "DURATION:-PT1D") + assertFixedEquals("TRIGGER:-P2D", "TRIGGER:-PT2D") + + assertFixedEquals("DURATION:-P1D", "DURATION:-P1DT") + assertFixedEquals("TRIGGER:-P2D", "TRIGGER:-P2DT") + } + + @Test + fun test_FixString_DayOffsetFrom_Valid() { + assertFixedEquals("DURATION:-PT12H", "DURATION:-PT12H") + assertFixedEquals("TRIGGER:-PT12H", "TRIGGER:-PT12H") + } + + @Test + fun test_FixString_DayOffsetFromMultiple_Invalid() { + assertFixedEquals("DURATION:-P1D\nTRIGGER:-P2D", "DURATION:-PT1D\nTRIGGER:-PT2D") + assertFixedEquals("DURATION:-P1D\nTRIGGER:-P2D", "DURATION:-P1DT\nTRIGGER:-P2DT") + } + + @Test + fun test_FixString_DayOffsetFromMultiple_Valid() { + assertFixedEquals("DURATION:-PT12H\nTRIGGER:-PT12H", "DURATION:-PT12H\nTRIGGER:-PT12H") + } + + @Test + fun test_FixString_DayOffsetFromMultiple_Mixed() { + assertFixedEquals("DURATION:-P1D\nDURATION:-PT12H\nTRIGGER:-P2D", "DURATION:-PT1D\nDURATION:-PT12H\nTRIGGER:-PT2D") + assertFixedEquals("DURATION:-P1D\nDURATION:-PT12H\nTRIGGER:-P2D", "DURATION:-P1DT\nDURATION:-PT12H\nTRIGGER:-P2DT") + } + + @Test + fun test_RegexpForProblem_DayOffsetTo_Invalid() { + val regex = FixInvalidDayOffsetPreprocessor.regexpForProblem() + assertTrue(regex.matches("DURATION:PT2D")) + assertTrue(regex.matches("TRIGGER:PT1D")) + } + + @Test + fun test_RegexpForProblem_DayOffsetTo_Valid() { + val regex = FixInvalidDayOffsetPreprocessor.regexpForProblem() + assertFalse(regex.matches("DURATION:-PT12H")) + assertFalse(regex.matches("TRIGGER:-PT15M")) + } + +} \ No newline at end of file diff --git a/src/test/java/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt similarity index 81% rename from src/test/java/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt rename to lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt index 52cf3aa6..561ee375 100644 --- a/src/test/java/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/validation/FixInvalidUtcOffsetPreprocessorTest.kt @@ -1,10 +1,14 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * This file is part of ical4android which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ package at.bitfire.ical4android.validation -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class FixInvalidUtcOffsetPreprocessorTest { diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..c692f1f0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,23 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "root" + +include ':lib' +project(':lib').name = 'ical4android' diff --git a/src/androidTest/java/at/bitfire/ical4android/ICalPreprocessorTest.kt b/src/androidTest/java/at/bitfire/ical4android/ICalPreprocessorTest.kt deleted file mode 100644 index 31238e2e..00000000 --- a/src/androidTest/java/at/bitfire/ical4android/ICalPreprocessorTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import at.bitfire.ical4android.validation.ICalPreprocessor -import net.fortuna.ical4j.data.CalendarBuilder -import net.fortuna.ical4j.model.Component -import net.fortuna.ical4j.model.component.VEvent -import org.apache.commons.io.IOUtils -import org.junit.Assert.assertEquals -import org.junit.Test -import java.io.InputStreamReader -import java.io.StringReader -import java.time.Duration - -class ICalPreprocessorTest { - - @Test - fun testFixInvalidUtcOffset() { - val invalid = "BEGIN:VEVENT" + - "SUMMARY:Test" + - "DTSTART;TZID=Test:19970714T133000" + - "END:VEVENT" + - "BEGIN:VTIMEZONE\n" + - "TZID:Test\n" + - "BEGIN:DAYLIGHT\n" + - "DTSTART:19670430T020000\n" + - "TZOFFSETFROM:-5730\n" + - "TZOFFSETTO:+1920\n" + - "TZNAME:EDT\n" + - "END:DAYLIGHT\n" + - "BEGIN:STANDARD\n" + - "DTSTART:19671029T020000\n" + - "TZOFFSETFROM:-0400\n" + - "TZOFFSETTO:-0500\n" + - "TZNAME:EST" + - "END:STANDARD\n" + - "END:VTIMEZONE" - val valid = "BEGIN:VEVENT" + - "SUMMARY:Test" + - "DTSTART;TZID=Test:19970714T133000" + - "END:VEVENT" + - "BEGIN:VTIMEZONE\n" + - "TZID:Test\n" + - "BEGIN:DAYLIGHT\n" + - "DTSTART:19670430T020000\n" + - "TZOFFSETFROM:-005730\n" + - "TZOFFSETTO:+001920\n" + - "TZNAME:EDT\n" + - "END:DAYLIGHT\n" + - "BEGIN:STANDARD\n" + - "DTSTART:19671029T020000\n" + - "TZOFFSETFROM:-0400\n" + - "TZOFFSETTO:-0500\n" + - "TZNAME:EST" + - "END:STANDARD\n" + - "END:VTIMEZONE" - ICalPreprocessor.preprocessStream(StringReader(invalid)).let { result -> - assertEquals(valid, IOUtils.toString(result)) - } - ICalPreprocessor.preprocessStream(StringReader(valid)).let { result -> - assertEquals(valid, IOUtils.toString(result)) - } - } - - @Test - fun testFixInvalidDuration() { - val invalid = "BEGIN:VEVENT\n" + - "LAST-MODIFIED:20230108T011226Z\n" + - "DTSTAMP:20230108T011226Z\n" + - "X-ECAL-SCHEDULE:63b0e38979739f000d5c1724\n" + - "DTSTART:20230101T015100Z\n" + - "DTEND:20230101T020600Z\n" + - "SUMMARY:This is a test event\n" + - "TRANSP:TRANSPARENT\n" + - "SEQUENCE:0\n" + - "UID:63b0e389453c5d000e1161ae\n" + - "PRIORITY:5\n" + - "X-MICROSOFT-CDO-IMPORTANCE:1\n" + - "CLASS:PUBLIC\n" + - "DESCRIPTION:Example description\n" + - "BEGIN:VALARM\n" + - "TRIGGER:-PT2D\n" + - "ACTION:DISPLAY\n" + - "DESCRIPTION:Reminder\n" + - "END:VALARM\n" + - "END:VEVENT" - val valid = "BEGIN:VEVENT\n" + - "LAST-MODIFIED:20230108T011226Z\n" + - "DTSTAMP:20230108T011226Z\n" + - "X-ECAL-SCHEDULE:63b0e38979739f000d5c1724\n" + - "DTSTART:20230101T015100Z\n" + - "DTEND:20230101T020600Z\n" + - "SUMMARY:This is a test event\n" + - "TRANSP:TRANSPARENT\n" + - "SEQUENCE:0\n" + - "UID:63b0e389453c5d000e1161ae\n" + - "PRIORITY:5\n" + - "X-MICROSOFT-CDO-IMPORTANCE:1\n" + - "CLASS:PUBLIC\n" + - "DESCRIPTION:Example description\n" + - "BEGIN:VALARM\n" + - "TRIGGER:-P2D\n" + - "ACTION:DISPLAY\n" + - "DESCRIPTION:Reminder\n" + - "END:VALARM\n" + - "END:VEVENT" - ICalPreprocessor.preprocessStream(StringReader(invalid)).let { result -> - assertEquals(valid, IOUtils.toString(result)) - } - ICalPreprocessor.preprocessStream(StringReader(valid)).let { result -> - assertEquals(valid, IOUtils.toString(result)) - } - } - - @Test - fun testMsTimeZones() { - javaClass.classLoader!!.getResourceAsStream("events/outlook1.ics").use { stream -> - val reader = InputStreamReader(stream, Charsets.UTF_8) - val calendar = CalendarBuilder().build(reader) - val vEvent = calendar.getComponent(Component.VEVENT) as VEvent - - assertEquals("W. Europe Standard Time", vEvent.startDate.timeZone.id) - ICalPreprocessor.preprocessCalendar(calendar) - assertEquals("Europe/Vienna", vEvent.startDate.timeZone.id) - } - } - -} \ No newline at end of file diff --git a/src/androidTest/java/at/bitfire/ical4android/impl/TestTask.kt b/src/androidTest/java/at/bitfire/ical4android/impl/TestTask.kt deleted file mode 100644 index 6c03dabd..00000000 --- a/src/androidTest/java/at/bitfire/ical4android/impl/TestTask.kt +++ /dev/null @@ -1,27 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android.impl - -import android.content.ContentValues - -import at.bitfire.ical4android.AndroidTask -import at.bitfire.ical4android.AndroidTaskFactory -import at.bitfire.ical4android.AndroidTaskList -import at.bitfire.ical4android.Task - -class TestTask: AndroidTask { - - constructor(taskList: AndroidTaskList, values: ContentValues) - : super(taskList, values) - - constructor(taskList: TestTaskList, task: Task) - : super(taskList, task) - - object Factory: AndroidTaskFactory { - override fun fromProvider(taskList: AndroidTaskList, values: ContentValues) = - TestTask(taskList, values) - } - -} diff --git a/src/androidTest/java/at/bitfire/ical4android/impl/TestTaskList.kt b/src/androidTest/java/at/bitfire/ical4android/impl/TestTaskList.kt deleted file mode 100644 index 0f4ef1e5..00000000 --- a/src/androidTest/java/at/bitfire/ical4android/impl/TestTaskList.kt +++ /dev/null @@ -1,42 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android.impl - -import android.accounts.Account -import android.content.ContentUris -import android.content.ContentValues -import at.bitfire.ical4android.AndroidTaskList -import at.bitfire.ical4android.AndroidTaskListFactory -import at.bitfire.ical4android.TaskProvider -import org.dmfs.tasks.contract.TaskContract - -class TestTaskList( - account: Account, - provider: TaskProvider, - id: Long -): AndroidTaskList(account, provider, TestTask.Factory, id) { - - companion object { - - fun create(account: Account, provider: TaskProvider): TestTaskList { - val values = ContentValues(4) - values.put(TaskContract.TaskListColumns.LIST_NAME, "Test Task List") - values.put(TaskContract.TaskListColumns.LIST_COLOR, 0xffff0000) - values.put(TaskContract.TaskListColumns.SYNC_ENABLED, 1) - values.put(TaskContract.TaskListColumns.VISIBLE, 1) - val uri = AndroidTaskList.create(account, provider, values) - - return TestTaskList(account, provider, ContentUris.parseId(uri)) - } - - } - - - object Factory: AndroidTaskListFactory { - override fun newInstance(account: Account, provider: TaskProvider, id: Long) = - TestTaskList(account, provider, id) - } - -} diff --git a/src/main/java/at/bitfire/ical4android/AndroidCalendarFactory.kt b/src/main/java/at/bitfire/ical4android/AndroidCalendarFactory.kt deleted file mode 100644 index a0ba2b28..00000000 --- a/src/main/java/at/bitfire/ical4android/AndroidCalendarFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import android.accounts.Account -import android.content.ContentProviderClient - -interface AndroidCalendarFactory> { - - fun newInstance(account: Account, provider: ContentProviderClient, id: Long): T - -} diff --git a/src/main/java/at/bitfire/ical4android/AndroidEventFactory.kt b/src/main/java/at/bitfire/ical4android/AndroidEventFactory.kt deleted file mode 100644 index 31369566..00000000 --- a/src/main/java/at/bitfire/ical4android/AndroidEventFactory.kt +++ /dev/null @@ -1,13 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import android.content.ContentValues - -interface AndroidEventFactory { - - fun fromProvider(calendar: AndroidCalendar, values: ContentValues): T - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/AndroidTaskListFactory.kt b/src/main/java/at/bitfire/ical4android/AndroidTaskListFactory.kt deleted file mode 100644 index f7f33bda..00000000 --- a/src/main/java/at/bitfire/ical4android/AndroidTaskListFactory.kt +++ /dev/null @@ -1,13 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import android.accounts.Account - -interface AndroidTaskListFactory> { - - fun newInstance(account: Account, provider: TaskProvider, id: Long): T - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/CalendarStorageException.kt b/src/main/java/at/bitfire/ical4android/CalendarStorageException.kt deleted file mode 100644 index 85f463b0..00000000 --- a/src/main/java/at/bitfire/ical4android/CalendarStorageException.kt +++ /dev/null @@ -1,12 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -class CalendarStorageException: Exception { - - constructor(message: String): super(message) - constructor(message: String, ex: Throwable): super(message, ex) - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/Ical4Android.kt b/src/main/java/at/bitfire/ical4android/Ical4Android.kt deleted file mode 100644 index 806e9810..00000000 --- a/src/main/java/at/bitfire/ical4android/Ical4Android.kt +++ /dev/null @@ -1,27 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import java.util.logging.Level -import java.util.logging.Logger - -object Ical4Android { - - val log: Logger = Logger.getLogger("ical4android") - - const val ical4jVersion = BuildConfig.version_ical4j - - - init { - if (BuildConfig.DEBUG) - log.level = Level.ALL - } - - fun checkThreadContextClassLoader() { - if (Thread.currentThread().contextClassLoader == null) - throw IllegalStateException("Thread.currentThread().contextClassLoader must be set") - } - -} diff --git a/src/main/java/at/bitfire/ical4android/InvalidCalendarException.kt b/src/main/java/at/bitfire/ical4android/InvalidCalendarException.kt deleted file mode 100644 index 6c0da588..00000000 --- a/src/main/java/at/bitfire/ical4android/InvalidCalendarException.kt +++ /dev/null @@ -1,12 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -class InvalidCalendarException: Exception { - - constructor(message: String): super(message) - constructor(message: String, ex: Throwable): super(message, ex) - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/JtxCollection.kt b/src/main/java/at/bitfire/ical4android/JtxCollection.kt deleted file mode 100644 index 83d54033..00000000 --- a/src/main/java/at/bitfire/ical4android/JtxCollection.kt +++ /dev/null @@ -1,308 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentUris -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues -import at.techbee.jtx.JtxContract -import at.techbee.jtx.JtxContract.asSyncAdapter -import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.component.VJournal -import net.fortuna.ical4j.model.component.VToDo -import net.fortuna.ical4j.model.property.Version -import java.util.* -import java.util.logging.Level - -open class JtxCollection(val account: Account, - val client: ContentProviderClient, - private val iCalObjectFactory: JtxICalObjectFactory, - val id: Long) { - - companion object { - - fun create(account: Account, client: ContentProviderClient, values: ContentValues): Uri { - Ical4Android.log.log(Level.FINE, "Creating jtx Board collection", values) - return client.insert(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), values) - ?: throw CalendarStorageException("Couldn't create JTX Collection") - } - - fun> find(account: Account, client: ContentProviderClient, context: Context, factory: JtxCollectionFactory, where: String?, whereArgs: Array?): List { - val collections = LinkedList() - client.query(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null)?.use { cursor -> - while (cursor.moveToNext()) { - val values = cursor.toValues() - val collection = factory.newInstance(account, client, values.getAsLong(JtxContract.JtxCollection.ID)) - collection.populate(values, context) - collections += collection - } - } - return collections - } - } - - - var url: String? = null - var displayname: String? = null - var syncstate: String? = null - - var supportsVEVENT = true - var supportsVTODO = true - var supportsVJOURNAL = true - - var context: Context? = null - - - fun delete() { - Ical4Android.log.log(Level.FINE, "Deleting jtx Board collection (#$id)") - client.delete(ContentUris.withAppendedId(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), id), null, null) - } - - fun update(values: ContentValues) { - Ical4Android.log.log(Level.FINE, "Updating jtx Board collection (#$id)", values) - client.update(ContentUris.withAppendedId(JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(account), id), values, null, null) - } - - protected fun populate(values: ContentValues, context: Context) { - url = values.getAsString(JtxContract.JtxCollection.URL) - displayname = values.getAsString(JtxContract.JtxCollection.DISPLAYNAME) - syncstate = values.getAsString(JtxContract.JtxCollection.SYNC_VERSION) - - supportsVEVENT = values.getAsString(JtxContract.JtxCollection.SUPPORTSVEVENT) == "1" - || values.getAsString(JtxContract.JtxCollection.SUPPORTSVEVENT) == "true" - supportsVTODO = values.getAsString(JtxContract.JtxCollection.SUPPORTSVTODO) == "1" - || values.getAsString(JtxContract.JtxCollection.SUPPORTSVTODO) == "true" - supportsVJOURNAL = values.getAsString(JtxContract.JtxCollection.SUPPORTSVJOURNAL) == "1" - || values.getAsString(JtxContract.JtxCollection.SUPPORTSVJOURNAL) == "true" - - this.context = context - } - - - /** - * Builds the JtxICalObject content uri with appended parameters for account and syncadapter - * @return the Uri for the JtxICalObject Sync in the content provider of jtx Board - */ - fun jtxSyncURI(): Uri = - JtxContract.JtxICalObject.CONTENT_URI.buildUpon() - .appendQueryParameter(JtxContract.ACCOUNT_NAME, account.name) - .appendQueryParameter(JtxContract.ACCOUNT_TYPE, account.type) - .appendQueryParameter(JtxContract.CALLER_IS_SYNCADAPTER, "true") - .build() - - - /** - * @return a list of content values of the deleted jtxICalObjects - */ - fun queryDeletedICalObjects(): List { - val values = mutableListOf() - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ?", arrayOf(id.toString(), "1"), null).use { cursor -> - Ical4Android.log.fine("findDeleted: found ${cursor?.count} deleted records in ${account.name}") - while (cursor?.moveToNext() == true) { - values.add(cursor.toValues()) - } - } - return values - } - - - /** - * @return a list of content values of the dirty jtxICalObjects - */ - fun queryDirtyICalObjects(): List { - val values = mutableListOf() - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ?", arrayOf(id.toString(), "1"), null).use { cursor -> - Ical4Android.log.fine("findDirty: found ${cursor?.count} dirty records in ${account.name}") - while (cursor?.moveToNext() == true) { - values.add(cursor.toValues()) - } - } - return values - } - - /** - * @param [filename] of the entry that should be retrieved as content values - * @return Content Values of the found item with the given filename or null if the result was empty or more than 1 - */ - fun queryByFilename(filename: String): ContentValues? { - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.FILENAME} = ?", arrayOf(id.toString(), filename), null).use { cursor -> - Ical4Android.log.fine("queryByFilename: found ${cursor?.count} records in ${account.name}") - if (cursor?.count != 1) - return null - cursor.moveToFirst() - return cursor.toValues() - } - } - - /** - * @param [uid] of the entry that should be retrieved as content values - * @return Content Values of the found item with the given UID or null if the result was empty or more than 1 - * The query checks for the [uid] within all collections of this account, not only the current collection. - */ - fun queryByUID(uid: String): ContentValues? { - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.UID} = ?", arrayOf(uid), null).use { cursor -> - Ical4Android.log.fine("queryByUID: found ${cursor?.count} records in ${account.name}") - if (cursor?.count != 1) - return null - cursor.moveToFirst() - return cursor.toValues() - } - } - - /** - * updates the flags of all entries in the collection with the given flag - * @param [flags] to be set - * @return the number of records that were updated - */ - fun updateSetFlags(flags: Int): Int { - val values = ContentValues(1) - values.put(JtxContract.JtxICalObject.FLAGS, flags) - return client.update(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), values, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ?", arrayOf(id.toString(), "0")) - } - - /** - * deletes all entries with the given flags - * @param [flags] of the entries that should be deleted - * @return the number of deleted records - */ - fun deleteByFlags(flags: Int) = - client.delete(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), "${JtxContract.JtxICalObject.DIRTY} = ? AND ${JtxContract.JtxICalObject.FLAGS} = ? ", arrayOf("0", flags.toString())) - - /** - * Updates the eTag value of all entries within a collection to the given eTag - * @param [eTag] to be set (or null) - */ - fun updateSetETag(eTag: String?) { - val values = ContentValues(1) - if(eTag == null) - values.putNull(JtxContract.JtxICalObject.ETAG) - else - values.put(JtxContract.JtxICalObject.ETAG, eTag) - client.update(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), values, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ?", arrayOf(id.toString())) - } - - - /** - * This function updates the Related-To relations in jtx Board. - * STEP 1: find entries to update (all entries with 0 in related-to). When inserting the relation, we only know the parent iCalObjectId and the related UID (but not the related iCalObjectId). - * In this step we search for all Related-To relations where the LINKEDICALOBJEC_ID is not set, resolve it through the UID and set it. - * STEP 2/3: jtx Board saves the relations in both directions, the Parent has an entry for his Child, the Child has an entry for his Parent. Step 2 and Step 3 make sure, that the Child-Parent pair is - * present in both directions. - */ - @Deprecated("Moved to jtx Board content provider (function updateRelatedTo()). This function here will be deleted in one of the next versions.") - fun updateRelatedTo() { - // STEP 1: first find entries to update (all entries with 0 in related-to) - client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.TEXT), "${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ?", arrayOf("0"), null).use { - while(it?.moveToNext() == true) { - val uid2upddate = it.getString(0) - - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxICalObject.ID), "${JtxContract.JtxICalObject.UID} = ?", arrayOf(uid2upddate), null).use { idOfthisUidCursor -> - if (idOfthisUidCursor?.moveToFirst() == true) { - val idOfthisUid = idOfthisUidCursor.getLong(0) - - val updateContentValues = ContentValues() - updateContentValues.put(JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, idOfthisUid) - client.update(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), updateContentValues,"${JtxContract.JtxRelatedto.TEXT} = ? AND ${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ?", arrayOf(uid2upddate, "0") - ) - } - } - } - } - - - // STEP 2: query all related to that are linking their PARENTS and check if they also have the opposite relationship entered, if not, then add it - client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(JtxContract.JtxRelatedto.Reltype.PARENT.name), null).use { - cursorAllLinkedParents -> - while (cursorAllLinkedParents?.moveToNext() == true) { - val childId = cursorAllLinkedParents.getString(0) - val parentId = cursorAllLinkedParents.getString(1) - - client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(parentId.toString(), childId.toString(), JtxContract.JtxRelatedto.Reltype.CHILD.name), null).use { cursor -> - // if the query does not bring any result, then we insert the opposite relationship - if (cursor?.moveToFirst() == false) { - //get the UID of the linked entry - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxICalObject.UID), "${JtxContract.JtxICalObject.ID} = ?", arrayOf(childId.toString()), null).use { - foundIcalObjectCursor -> - - if (foundIcalObjectCursor?.moveToFirst() == true) { - val childUID = foundIcalObjectCursor.getString(0) - val cv = ContentValues().apply { - put(JtxContract.JtxRelatedto.ICALOBJECT_ID, parentId) - put(JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, childId) - put(JtxContract.JtxRelatedto.RELTYPE, JtxContract.JtxRelatedto.Reltype.CHILD.name) - put(JtxContract.JtxRelatedto.TEXT, childUID) - } - client.insert(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), cv) - } - } - } - } - } - } - - - // STEP 3: query all related to that are linking their CHILD and check if they also have the opposite relationship entered, if not, then add it - client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(JtxContract.JtxRelatedto.Reltype.CHILD.name), null).use { - cursorAllLinkedParents -> - while (cursorAllLinkedParents?.moveToNext() == true) { - - val parentId = cursorAllLinkedParents.getLong(0) - val childId = cursorAllLinkedParents.getLong(1) - - client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(childId.toString(), parentId.toString(), JtxContract.JtxRelatedto.Reltype.PARENT.name), null).use { - cursor -> - - // if the query does not bring any result, then we insert the opposite relationship - if (cursor?.moveToFirst() == false) { - - //get the UID of the linked entry - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxICalObject.UID), "${JtxContract.JtxICalObject.ID} = ?", arrayOf(parentId.toString()), null).use { - foundIcalObjectCursor -> - - if(foundIcalObjectCursor?.moveToFirst() == true) { - val parentUID = foundIcalObjectCursor.getString(0) - val cv = ContentValues().apply { - put(JtxContract.JtxRelatedto.ICALOBJECT_ID, childId) - put(JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, parentId) - put(JtxContract.JtxRelatedto.RELTYPE, JtxContract.JtxRelatedto.Reltype.PARENT.name) - put(JtxContract.JtxRelatedto.TEXT, parentUID) - } - client.insert(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), cv) - } - } - } - } - } - } - } - - /** - * @return a string with all JtxICalObjects within the collection as iCalendar - */ - fun getICSForCollection(): String { - client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ?", arrayOf(id.toString(), "0"), null).use { cursor -> - Ical4Android.log.fine("getICSForCollection: found ${cursor?.count} records in ${account.name}") - - val ical = Calendar() - ical.properties += Version.VERSION_2_0 - ical.properties += ICalendar.prodId - - while (cursor?.moveToNext() == true) { - val jtxIcalObject = JtxICalObject(this) - jtxIcalObject.populateFromContentValues(cursor.toValues()) - val singleICS = jtxIcalObject.getICalendarFormat() - singleICS?.components?.forEach { component -> - if(component is VToDo || component is VJournal) - ical.components += component - } - } - return ical.toString() - } - } -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/JtxCollectionFactory.kt b/src/main/java/at/bitfire/ical4android/JtxCollectionFactory.kt deleted file mode 100644 index 177b2d11..00000000 --- a/src/main/java/at/bitfire/ical4android/JtxCollectionFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import android.accounts.Account -import android.content.ContentProviderClient - -interface JtxCollectionFactory> { - - fun newInstance(account: Account, client: ContentProviderClient, id: Long): T - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/JtxICalObjectFactory.kt b/src/main/java/at/bitfire/ical4android/JtxICalObjectFactory.kt deleted file mode 100644 index 8f6648cb..00000000 --- a/src/main/java/at/bitfire/ical4android/JtxICalObjectFactory.kt +++ /dev/null @@ -1,13 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import android.content.ContentValues - -interface JtxICalObjectFactory { - - fun fromProvider(collection: JtxCollection, values: ContentValues): T - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/UsesThreadContextClassLoader.kt b/src/main/java/at/bitfire/ical4android/UsesThreadContextClassLoader.kt deleted file mode 100644 index 8bc96baf..00000000 --- a/src/main/java/at/bitfire/ical4android/UsesThreadContextClassLoader.kt +++ /dev/null @@ -1,13 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -@Target(AnnotationTarget.CLASS, AnnotationTarget.CONSTRUCTOR, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) -@Retention(AnnotationRetention.SOURCE) -@MustBeDocumented -/** - * Requires the current thread's [Thread.getContextClassLoader] to be set (not null). - */ -annotation class UsesThreadContextClassLoader \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/util/MiscUtils.kt b/src/main/java/at/bitfire/ical4android/util/MiscUtils.kt deleted file mode 100644 index 76b3e02e..00000000 --- a/src/main/java/at/bitfire/ical4android/util/MiscUtils.kt +++ /dev/null @@ -1,108 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android.util - -import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentValues -import android.database.Cursor -import android.database.DatabaseUtils -import android.net.Uri -import android.os.Build -import android.provider.CalendarContract -import org.apache.commons.lang3.StringUtils -import java.lang.reflect.Modifier -import java.util.* - -object MiscUtils { - - const val TOSTRING_MAXCHARS = 10000 - - /** - * Generates useful toString info (fields and values) from [obj] by reflection. - * - * @param obj object to inspect - * @return string containing properties and non-static declared fields - */ - fun reflectionToString(obj: Any): String { - val s = LinkedList() - var clazz: Class? = obj.javaClass - while (clazz != null) { - for (prop in clazz.declaredFields.filterNot { Modifier.isStatic(it.modifiers) }) { - prop.isAccessible = true - val valueStr = try { - StringUtils.abbreviate(prop.get(obj)?.toString(), TOSTRING_MAXCHARS) - } catch(e: OutOfMemoryError) { - "![$e]" - } - s += "${prop.name}=" + valueStr - } - clazz = clazz.superclass - } - return "${obj.javaClass.simpleName}=[${s.joinToString(", ")}]" - } - - /** - * Removes empty [String] values from [values]. - * - * @param values set of values to be modified - * @return the modified object (which is the same object as passed in; for chaining) - */ - fun removeEmptyStrings(values: ContentValues): ContentValues { - val it = values.keySet().iterator() - while (it.hasNext()) { - val obj = values[it.next()] - if (obj is String && obj.isEmpty()) - it.remove() - } - return values - } - - - object ContentProviderClientHelper { - - fun ContentProviderClient.closeCompat() { - @Suppress("DEPRECATION") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - close() - else - release() - } - - } - - - object CursorHelper { - - /** - * Returns the entire contents of the current row as a [ContentValues] object. - * - * @param removeEmptyRows whether rows with empty values should be removed - * @return entire contents of the current row - */ - fun Cursor.toValues(removeEmptyRows: Boolean = false): ContentValues { - val values = ContentValues(columnCount) - DatabaseUtils.cursorRowToContentValues(this, values) - - if (removeEmptyRows) - removeEmptyStrings(values) - - return values - } - - } - - - object UriHelper { - - fun Uri.asSyncAdapter(account: Account): Uri = buildUpon() - .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, account.name) - .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, account.type) - .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") - .build() - - } - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt b/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt deleted file mode 100644 index 71e61477..00000000 --- a/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt +++ /dev/null @@ -1,154 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android.validation - -import at.bitfire.ical4android.Event -import at.bitfire.ical4android.Ical4Android -import at.bitfire.ical4android.InvalidCalendarException -import at.bitfire.ical4android.util.DateUtils -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate -import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate -import at.bitfire.ical4android.util.TimeApiExtensions.toZoneIdCompat -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.Recur -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.RRule -import net.fortuna.ical4j.util.TimeZones -import java.time.LocalTime -import java.time.ZonedDateTime -import java.util.* - -/** - * Sometimes CalendarStorage or servers respond with invalid event definitions. Here we try to - * validate, repair and assume whatever seems appropriate before denying the whole event. - */ -class EventValidator(val e: Event) { - - fun repair() { - val dtStart = correctStartAndEndTime(e) - sameTypeForDtStartAndRruleUntil(dtStart, e.rRules) - removeRRulesWithUntilBeforeDtStart(dtStart, e.rRules) - } - - companion object { - /** - * Ensure proper start and end time - */ - internal fun correctStartAndEndTime(e: Event): DtStart { - val dtStart = e.dtStart ?: throw InvalidCalendarException("Event without start time") - e.dtEnd?.let { dtEnd -> - if (dtStart.date > dtEnd.date) { - Ical4Android.log.warning("DTSTART after DTEND; removing DTEND") - e.dtEnd = null - } - } - return dtStart - } - - /** - * Tries to make the value type of UNTIL and DTSTART the same (both DATE or DATETIME). - */ - internal fun sameTypeForDtStartAndRruleUntil(dtStart: DtStart, rRules: MutableList) { - if (DateUtils.isDate(dtStart)) { - // DTSTART is a DATE - val newRRules = mutableListOf() - val rRuleIterator = rRules.iterator() - while (rRuleIterator.hasNext()) { - val rRule = rRuleIterator.next() - rRule.recur.until?.let { until -> - if (until is DateTime) { - Ical4Android.log.warning("DTSTART has DATE, but UNTIL has DATETIME; making UNTIL have DATE only") - - val newUntil = until.toLocalDate().toIcal4jDate() - - // remove current RRULE and remember new one to be added - val newRRule = RRule(Recur.Builder(rRule.recur) - .until(newUntil) - .build()) - Ical4Android.log.info("New $newRRule (was ${rRule.toString().trim()})") - newRRules += newRRule - rRuleIterator.remove() - } - } - } - // add repaired RRULEs - rRules += newRRules - - } else if (DateUtils.isDateTime(dtStart)) { - // DTSTART is a DATE-TIME - val newRRules = mutableListOf() - val rRuleIterator = rRules.iterator() - while (rRuleIterator.hasNext()) { - val rRule = rRuleIterator.next() - rRule.recur.until?.let { until -> - if (until !is DateTime) { - Ical4Android.log.warning("DTSTART has DATETIME, but UNTIL has DATE; copying time from DTSTART to UNTIL") - val dtStartTimeZone = if (dtStart.timeZone != null) - dtStart.timeZone - else if (dtStart.isUtc) - TimeZones.getUtcTimeZone() - else /* floating time */ - TimeZone.getDefault() - - val dtStartCal = Calendar.getInstance(dtStartTimeZone).apply { - time = dtStart.date - } - val dtStartTime = LocalTime.of( - dtStartCal.get(Calendar.HOUR_OF_DAY), - dtStartCal.get(Calendar.MINUTE), - dtStartCal.get(Calendar.SECOND) - ) - - val newUntil = ZonedDateTime.of( - until.toLocalDate(), // date from until - dtStartTime, // time from dtStart - dtStartTimeZone.toZoneIdCompat() - ) - - // Android requires UNTIL in UTC as defined in RFC 2445. - // https://android.googlesource.com/platform/frameworks/opt/calendar/+/refs/tags/android-12.1.0_r27/src/com/android/calendarcommon2/RecurrenceProcessor.java#93 - val newUntilUTC = DateTime(true).apply { - time = newUntil.toInstant().toEpochMilli() - } - - // remove current RRULE and remember new one to be added - val newRRule = RRule(Recur.Builder(rRule.recur) - .until(newUntilUTC) - .build()) - Ical4Android.log.info("New $newRRule (was ${rRule.toString().trim()})") - newRRules += newRRule - rRuleIterator.remove() - } - } - } - // add repaired RRULEs - rRules += newRRules - } else - throw InvalidCalendarException("Event with invalid DTSTART value") - } - - /** - * Will remove the RRULES of an event where UNTIL lies before DTSTART - */ - internal fun removeRRulesWithUntilBeforeDtStart(dtStart: DtStart, rRules: MutableList) { - val iter = rRules.iterator() - while (iter.hasNext()) { - val rRule = iter.next() - - // drop invalid RRULEs - if (hasUntilBeforeDtStart(dtStart, rRule)) - iter.remove() - } - } - - /** - * Checks whether UNTIL of an RRULE lies before DTSTART - */ - internal fun hasUntilBeforeDtStart(dtStart: DtStart, rRule: RRule): Boolean { - val until = rRule.recur.until ?: return false - return until < dtStart.date - } - } -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt b/src/main/java/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt deleted file mode 100644 index 843510cf..00000000 --- a/src/main/java/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessor.kt +++ /dev/null @@ -1,30 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android.validation - -/** - * Fixes durations with day offsets with the 'T' prefix. - * See also https://github.com/bitfireAT/icsx5/issues/100 - */ -object FixInvalidDayOffsetPreprocessor : StreamPreprocessor() { - - override fun regexpForProblem() = Regex( - "^(DURATION|TRIGGER):-?PT-?\\d+D$", - setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE) - ) - - override fun fixString(original: String): String { - var s: String = original - - // Find all matches for the expression - val found = regexpForProblem().find(s) ?: return s - for (match in found.groupValues) { - val fixed = match.replace("PT", "P") - s = s.replace(match, fixed) - } - return s - } - -} \ No newline at end of file diff --git a/src/test/java/at/bitfire/ical4android/MiscUtilsTest.kt b/src/test/java/at/bitfire/ical4android/MiscUtilsTest.kt deleted file mode 100644 index c9cf19d1..00000000 --- a/src/test/java/at/bitfire/ical4android/MiscUtilsTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import at.bitfire.ical4android.util.MiscUtils -import org.junit.Assert.assertTrue -import org.junit.Test - -class MiscUtilsTest { - - @Test - fun testReflectionToString() { - val s = MiscUtils.reflectionToString(MiscUtilsTest.TestClass()) - assertTrue(s.startsWith("TestClass=[")) - assertTrue(s.contains("i=2")) - assertTrue(s.contains("large=null")) - assertTrue(s.contains("s=test")) - } - - @Test - fun testReflectionToString_OOM() { - val t = MiscUtilsTest.TestClass() - t.large = object: Any() { - override fun toString(): String { - throw OutOfMemoryError("toString() causes OOM") - } - } - val s = MiscUtils.reflectionToString(t) - assertTrue(s.startsWith("TestClass=[")) - assertTrue(s.contains("large=![java.lang.OutOfMemoryError")) - } - - - @Suppress("unused") - private class TestClass { - val i = 2 - var large: Any? = null - private val s = "test" - } - -} \ No newline at end of file diff --git a/src/test/java/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt b/src/test/java/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt deleted file mode 100644 index 7471ca8e..00000000 --- a/src/test/java/at/bitfire/ical4android/validation/FixInvalidDayOffsetPreprocessorTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android.validation - -import org.junit.Assert.* -import org.junit.Test - -class FixInvalidDayOffsetPreprocessorTest { - - @Test - fun test_FixString_NoOccurrence() { - assertEquals( - "Some String", - FixInvalidDayOffsetPreprocessor.fixString("Some String"), - ) - } - - @Test - fun test_FixString_DayOffsetFrom_Invalid() { - assertEquals( - "DURATION:-P1D", - FixInvalidDayOffsetPreprocessor.fixString("DURATION:-PT1D"), - ) - assertEquals( - "TRIGGER:-P2D", - FixInvalidDayOffsetPreprocessor.fixString("TRIGGER:-PT2D"), - ) - } - - @Test - fun test_FixString_DayOffsetFrom_Valid() { - assertEquals( - "DURATION:-PT12H", - FixInvalidDayOffsetPreprocessor.fixString("DURATION:-PT12H"), - ) - assertEquals( - "TRIGGER:-PT12H", - FixInvalidDayOffsetPreprocessor.fixString("TRIGGER:-PT12H"), - ) - } - - @Test - fun test_RegexpForProblem_DayOffsetTo_Invalid() { - val regex = FixInvalidDayOffsetPreprocessor.regexpForProblem() - assertTrue(regex.matches("DURATION:PT2D")) - assertTrue(regex.matches("TRIGGER:PT1D")) - } - - @Test - fun test_RegexpForProblem_DayOffsetTo_Valid() { - val regex = FixInvalidDayOffsetPreprocessor.regexpForProblem() - assertFalse(regex.matches("DURATION:-PT12H")) - assertFalse(regex.matches("TRIGGER:-PT15M")) - } - -} \ No newline at end of file