diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 59271e61e01950f714bf0516d1b71d5c39248750..0811824d5202160fe14bee8a342f9390083bf4d2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ -custom: [ 'https://icsx5.bitfire.at/donate/' ] +github: bitfireAT +custom: 'https://icsx5.bitfire.at/donate/' diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..02026d3cd3ee41aaded587596e246b26f921257f --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,17 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: New features + labels: + - enhancement + - title: Bug fixes + labels: + - bug + - title: Refactoring + labels: + - refactoring + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000000000000000000000000000000000..a3df4e2500770bec9918d5f915b8723c33fda40b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,49 @@ +name: "CodeQL" + +on: + push: + branches: [ "dev", main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "dev" ] + schedule: + - cron: '34 7 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + - uses: gradle/gradle-build-action@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + - name: Build + run: ./gradlew --no-daemon app:assembleStandardDebug + + - 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 0000000000000000000000000000000000000000..9e2244d65f48d6221efbde749b5d2e00b073b539 --- /dev/null +++ b/.github/workflows/dependent-issues.yml @@ -0,0 +1,54 @@ +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/release.yml b/.github/workflows/release.yml index 2a922fb868b9ba1a3407e2574a77b37011053bda..a3a0db2ee8ec6f8d1f139c53adcb76f24da21277 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,21 +10,21 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: true - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Prepare keystore run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks - name: Build signed packages - run: ./gradlew app:assembleRelease + # AboutLibraries 10.6.3 doesn't show any dependencies when configuration cache is used + run: ./gradlew --no-configuration-cache --no-daemon app:assembleRelease env: ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }} @@ -32,10 +32,11 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }} - name: Create Github release (from standard flavor) - uses: softprops/action-gh-release@v0.1.14 + uses: softprops/action-gh-release@v1 with: prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} files: app/build/outputs/apk/standard/release/*.apk + generate_release_notes: true fail_on_unmatched_files: true - name: Upload to Google Play (gplay flavor) @@ -47,4 +48,3 @@ jobs: mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt track: internal status: draft - diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 4aa34cc0b351dbcbf9a8b7dae37e7d84f7c32c80..b5c55657e71a7f231f381b51deeb5f4ef8490f5b 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -5,19 +5,20 @@ jobs: name: Tests without emulator runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: true - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check run: ./gradlew app:lintStandardDebug app:testStandardDebugUnitTest + - name: Archive results + if: always() uses: actions/upload-artifact@v2 with: name: test-results @@ -27,31 +28,56 @@ jobs: test_on_emulator: 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-4-cores + strategy: + matrix: + api-level: [31] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: true - - uses: gradle/wrapper-validation-action@v1 + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + - uses: gradle/gradle-build-action@v2 + + - 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 gradle dependencies - uses: actions/cache@v2 + - name: Cache AVD + uses: actions/cache@v3 + id: avd-cache with: - key: ${{ runner.os }}-1 path: | - ~/.gradle/caches - ~/.gradle/wrapper + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + 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: Run tests + uses: reactivecircus/android-emulator-runner@v2 + 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: ./gradlew app:connectedStandardDebugAndroidTest - - name: Start emulator - run: start-emulator.sh - - name: Run connected tests - run: ./gradlew app:connectedStandardDebugAndroidTest - name: Archive results if: always() uses: actions/upload-artifact@v2 @@ -59,4 +85,3 @@ jobs: name: test-results path: | app/build/reports - diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 698e100ec418efa05b848ddeaa70035b4693acd9..e3962c7a8847697a00c9f8633486584c5dbf7bb0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,4 @@ -image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest" - +image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:1263-Add_java_17_support" stages: - update-from-upstream - build @@ -16,9 +15,6 @@ cache: build: stage: build - before_script: - - git submodule sync - - git submodule update --init --recursive --force script: - ./gradlew build artifacts: @@ -63,8 +59,6 @@ build: - git checkout -b $TEMP_LATEST_TAG_BRANCH # merge $LOCAL_BRANCH with $TEMP_LATEST_TAG_BRANCH & push - git checkout $LOCAL_BRANCH - - git submodule sync - - git submodule update --init --recursive --force - git merge $TEMP_LATEST_TAG_BRANCH - git push origin $LOCAL_BRANCH # remove unwanted local branch & remote @@ -79,5 +73,3 @@ update-default-branch: UPSTREAM_DEFAULT_BRANCH: main UPSTREAM_URL: https://github.com/bitfireAT/icsx5.git TEMP_LATEST_TAG_BRANCH: latest_upstream_tag_branch - - diff --git a/.gitmodules b/.gitmodules index 2248b67dc52801c1706b3096ae8120f7aa51c082..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +0,0 @@ -[submodule "ical4android"] - path = ical4android - url = https://gitlab.e.foundation/e/os/ical4android.git -[submodule "cert4android"] - path = cert4android - url = https://github.com/bitfireAT/cert4android.git diff --git a/.tx/config b/.tx/config index 3f0946223533cc9907a7b5327db1412a5f5f038d..d2d8fa8d101b08658c0ef6fc52d27510522e8ca8 100644 --- a/.tx/config +++ b/.tx/config @@ -1,12 +1,10 @@ [main] host = https://www.transifex.com +lang_map = pl_PL: pl-rPL, pt_BR: pt-rBR, pt_PT: pt-rPT, ru_UA: ru-rUA, uk_UA: uk-rUA -[icsx5.icsx5] -file_filter = app/src/main/res/values-/strings.xml +[o:bitfireAT:p:icsx5:r:icsx5] +file_filter = app/src/main/res/values-/strings.xml +source_file = app/src/main/res/values/strings.xml +source_lang = en +type = ANDROID minimum_perc = 0 -source_file = app/src/main/res/values/strings.xml -source_lang = en -trans.pt_PT = app/src/main/res/values-pt/strings.xml -trans.pt_BR = app/src/main/res/values-pt-rBR/strings.xml -type = ANDROID - diff --git a/README.md b/README.md index 1e1672709ffd2a316d01aeb803155db29b8e4daf..2b9ccb2c0d04ac2afb66234150e7da4c46589728 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,23 @@ time tables of your school/university or event files of your sports team). Please see the [ICSx⁵ Web site](https://icsx5.bitfire.at) for comprehensive information about ICSx⁵. -News and updates: [@icsx5app](https://twitter.com/icsx5app) +News and updates: [@davx5app@fosstodon.org](https://fosstodon.org/@davx5app) Help, discussion, ideas, bug reports: [ICSx⁵ forum](https://icsx5.bitfire.at/forums/) + + +Contributions +======= + We're happy about contributions! Just send a pull request for small changes or in case of bigger changes, please let us know in the forum before. +## Translations +ICSx⁵ is available [on Transifex](https://www.transifex.com/bitfireAT/icsx5/). There you can propose +changes to the translations, or create new ones. Feel free to suggest new languages, people will +love it. + License diff --git a/app/build.gradle b/app/build.gradle index 6dcd82ac2ca695c843f5bbb2e906083d19da49e2..f20ec3fd2e3fe951d1ba165f46da17c3344a0964 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,37 +1,48 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.devtools.ksp' android { - compileSdkVersion 32 - buildToolsVersion '32.0.0' + compileSdkVersion 33 + buildToolsVersion '33.0.2' + + namespace 'at.bitfire.icsdroid' defaultConfig { applicationId "foundation.e.webcalendarmanager" minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 33 - versionCode 66 - versionName "2.0.3" + versionCode 73 + versionName "2.2-beta.1" setProperty "archivesBaseName", "WebCalendarManager-" + getVersionName() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + ksp { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } } compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } buildFeatures { - viewBinding = true + buildConfig = true + compose = true dataBinding = true + viewBinding = true + } + + composeOptions { + // Keep in sync with Kotlin version: https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion = '1.4.7' } flavorDimensions "distribution" @@ -69,27 +80,53 @@ android { } } +configurations { + all { + // exclude modules which are in conflict with system libraries + exclude module: "commons-logging" + exclude group: "org.json", module: "json" + + // Groovy requires SDK 26+, and it's not required, so exclude it + exclude group: 'org.codehaus.groovy' + } +} + dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' - implementation project(':cert4android') - implementation project(':ical4android') + implementation 'com.github.bitfireAT:cert4android:3817e62d9f173d8f8b800d24769f42cb205f560e' + implementation files('../libs/ical4android.aar') - implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'org.mnode.ical4j:ical4j:3.2.11' + + implementation 'foundation.e:elib:0.0.1-alpha11' + + implementation 'androidx.activity:activity-compose:1.7.1' + implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.fragment:fragment-ktx:1.5.2' + implementation 'androidx.core:core-ktx:1.10.1' + implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.work:work-runtime-ktx:2.7.1' - implementation 'com.google.android.material:material:1.6.1' + implementation 'androidx.work:work-runtime-ktx:2.8.1' + implementation 'com.google.android.material:material:1.9.0' + + // Jetpack Compose + def composeBom = platform("androidx.compose:compose-bom:${versions.composeBom}") + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material:material' + debugImplementation "androidx.compose.ui:ui-tooling" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1' implementation 'com.jaredrummler:colorpicker:1.1.0' implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-coroutines:${versions.okhttp}" + implementation "joda-time:joda-time:2.12.5" // latest commons that don't require Java 8 //noinspection GradleDependency @@ -97,12 +134,17 @@ dependencies { //noinspection GradleDependency implementation 'org.apache.commons:commons-lang3:3.8.1' + // Room Database + implementation "androidx.room:room-ktx:${versions.room}" + ksp "androidx.room:room-compiler:${versions.room}" + // for tests - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation "androidx.test:rules:1.4.0" - androidTestImplementation "androidx.arch.core:core-testing:2.1.0" + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation "androidx.test:rules:1.5.0" + androidTestImplementation "androidx.arch.core:core-testing:2.2.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" + androidTestImplementation "androidx.work:work-testing:2.8.1" testImplementation 'junit:junit:4.13.2' } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a6e5b6273bf4af97fe38a7416059e530c9c624f2..ff1a59e258f2a754bd82252a4ce2cf139d8163a4 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,20 +7,25 @@ -dontobfuscate --optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* --optimizationpasses 5 --allowaccessmodification --dontpreverify +# ical4j: keep all iCalendar properties/parameters (used via reflection) +-keep class net.fortuna.ical4j.** { *; } -# ical4j: ignore unused dynamic libraries --dontwarn aQute.** --dontwarn groovy.** # Groovy-based ContentBuilder not used --dontwarn javax.cache.** # no JCache support in Android --dontwarn net.fortuna.ical4j.model.** --dontwarn org.codehaus.groovy.** --dontwarn org.apache.log4j.** # ignore warnings from log4j dependency --keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime) --keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing) +# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations) +-keepclassmembers,allowoptimization enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} # keep ICSx⁵ and ical4android -keep class at.bitfire.** { *; } # all ICSx⁵ code is required + +# Additional rules which are now required since missing classes can't be ignored in R8 anymore. +# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning] +-dontwarn groovy.** +-dontwarn java.beans.Transient +-dontwarn org.codehaus.groovy.** +-dontwarn org.joda.** +-dontwarn org.json.* +-dontwarn org.xmlpull.** + +-dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json new file mode 100644 index 0000000000000000000000000000000000000000..be60d10ef85d5e6ca38914bab30409a0ec968407 --- /dev/null +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json @@ -0,0 +1,132 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "aa152cc4e5846c386d67f531d02ab2fe", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreEmbeddedAlerts", + "columnName": "ignoreEmbeddedAlerts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAlarmMinutes", + "columnName": "defaultAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aa152cc4e5846c386d67f531d02ab2fe')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json new file mode 100644 index 0000000000000000000000000000000000000000..4171c81b13f55ee8a9354cadc716060c98952a54 --- /dev/null +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json @@ -0,0 +1,138 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d61bf6fb08b622a180a1933b983faae2", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `calendarId` INTEGER, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarId", + "columnName": "calendarId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreEmbeddedAlerts", + "columnName": "ignoreEmbeddedAlerts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAlarmMinutes", + "columnName": "defaultAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd61bf6fb08b622a180a1933b983faae2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json new file mode 100644 index 0000000000000000000000000000000000000000..65b90f155cc61fe6705f3de525e628571f989ff3 --- /dev/null +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json @@ -0,0 +1,144 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "67f30d5e1a3b0c6b44f357e00170f6ab", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `calendarId` INTEGER, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `defaultAllDayAlarmMinutes` INTEGER, `color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarId", + "columnName": "calendarId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreEmbeddedAlerts", + "columnName": "ignoreEmbeddedAlerts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAlarmMinutes", + "columnName": "defaultAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "defaultAllDayAlarmMinutes", + "columnName": "defaultAllDayAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67f30d5e1a3b0c6b44f357e00170f6ab')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..4fba697d6c85e775b003e69a097b0395b5867b3e --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt @@ -0,0 +1,194 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.migration + +import android.Manifest +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.CalendarContract.Calendars +import android.util.Log +import androidx.core.content.contentValuesOf +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.work.Configuration +import androidx.work.Data +import androidx.work.ListenableWorker.Result +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.icsdroid.AppAccount +import at.bitfire.icsdroid.Constants.TAG +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.calendar.LocalCalendar +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.CalendarCredentials +import at.bitfire.icsdroid.db.dao.CredentialsDao +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Subscription +import kotlinx.coroutines.runBlocking +import org.junit.* +import org.junit.Assert.* + +class CalendarToRoomMigrationTest { + + companion object { + @JvmField + @ClassRule + val calendarPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR, + ) + + val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun setUpProvider() { + provider = LocalCalendar.getCalendarProvider(appContext) + } + + @AfterClass + @JvmStatic + fun closeProvider() { + provider.closeCompat() + } + + const val CALENDAR_DISPLAY_NAME = "Some subscription" + const val CALENDAR_URL = "https://example.com/test.ics" + const val CALENDAR_USERNAME = "someUser" + const val CALENDAR_PASSWORD = "somePassword" + + } + + /** Provides an in-memory interface to the app's database */ + private lateinit var db: AppDatabase + private lateinit var credentialsDao: CredentialsDao + private lateinit var subscriptionsDao: SubscriptionsDao + + // Initialize the test WorkManager for scheduling workers + @Before + fun prepareWorkManager() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(appContext, config) + } + + // Initialize the Room database + @Before + fun prepareDatabase() { + assertNotNull(appContext) + + db = Room.inMemoryDatabaseBuilder(appContext, AppDatabase::class.java).build() + credentialsDao = db.credentialsDao() + subscriptionsDao = db.subscriptionsDao() + + AppDatabase.setInstance(db) + } + + + private fun createCalendar(): LocalCalendar { + val account = AppAccount.get(appContext) + val uri = AndroidCalendar.create( + account, + provider, + contentValuesOf( + Calendars.CALENDAR_DISPLAY_NAME to CALENDAR_DISPLAY_NAME, + Calendars.NAME to CALENDAR_URL + ) + ) + val calendarId = ContentUris.parseId(uri) + Log.i(TAG, "Created test calendar $calendarId") + + val calendar = AndroidCalendar.findByID( + account, + provider, + LocalCalendar.Factory, + calendarId + ) + + // associate credentials, too + CalendarCredentials(appContext).put(calendar, CALENDAR_USERNAME, CALENDAR_PASSWORD) + + return calendar + } + + @Test + fun testMigrateFromV2_0_3() { + // prepare: create local calendar without subscription + val calendar = createCalendar() + assertFalse(calendar.isManagedByDB()) + + try { + runBlocking { + // run worker + val result = TestListenableWorkerBuilder(appContext) + .setInputData(Data.Builder() + .putBoolean(SyncWorker.ONLY_MIGRATE, true) + .build()) + .build().doWork() + assertEquals(Result.success(), result) + + // check that calendar is marked as "managed by DB" so that it won't be migrated again + assertTrue(calendar.isManagedByDB()) + + // check that the subscription has been added + val subscription = subscriptionsDao.getByCalendarId(calendar.id)!! + assertEquals(calendar.id, subscription.calendarId) + assertEquals(CALENDAR_DISPLAY_NAME, subscription.displayName) + assertEquals(Uri.parse(CALENDAR_URL), subscription.url) + + // check credentials, too + val credentials = credentialsDao.getBySubscriptionId(subscription.id) + assertEquals(CALENDAR_USERNAME, credentials?.username) + assertEquals(CALENDAR_PASSWORD, credentials?.password) + } + } finally { + calendar.delete() + } + } + + @Test + fun testMigrateFromV2_1() { + // prepare: create local calendar plus subscription with subscription.id = LocalCalendar.id, + // but with calendarId=null and COLUMN_MANAGED_BY_DB=null + val calendar = createCalendar() + assertFalse(calendar.isManagedByDB()) + + val oldSubscriptionId = subscriptionsDao.add(Subscription.fromLegacyCalendar(calendar).copy(id = calendar.id, calendarId = null)) + + try { + runBlocking { + // run worker + val result = TestListenableWorkerBuilder(appContext) + .setInputData(Data.Builder() + .putBoolean(SyncWorker.ONLY_MIGRATE, true) + .build()) + .build().doWork() + assertEquals(Result.success(), result) + + // check that calendar is marked as "managed by DB" so that it won't be migrated again + assertTrue(calendar.isManagedByDB()) + + // check that the subscription has been added + val subscription = subscriptionsDao.getByCalendarId(calendar.id)!! + assertEquals(oldSubscriptionId, subscription.id) + assertEquals(calendar.id, subscription.calendarId) + assertEquals(CALENDAR_DISPLAY_NAME, subscription.displayName) + assertEquals(Uri.parse(CALENDAR_URL), subscription.url) + } + } finally { + calendar.delete() + } + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 206c460511eee58ead9a97b575ea75c66308f8cd..0c81fc7f1a9bb032909e444394276e08b02c899e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,15 +29,21 @@ + + + android:windowSoftInputMode="stateAlwaysHidden" /> + + + \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt b/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt index 051d1a48440677d359055cae8fa9c3c87a4830f2..5f1a5d5315d0092eea84b52d0fb8beed5fa21374 100644 --- a/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt +++ b/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt @@ -14,7 +14,7 @@ import android.util.Log object AppAccount { private const val DEFAULT_SYNC_INTERVAL = 24*3600L // 1 day - const val SYNC_INTERVAL_MANUALLY = -1L + private const val SYNC_INTERVAL_MANUALLY = -1L private const val PREF_ACCOUNT = "account" private const val KEY_SYNC_INTERVAL = "syncInterval" diff --git a/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt index 0b2145f11406eaba21121e188e0d2aeec4878d1d..6a6d1c4076cbd382677645dde9dc39c7d52f18e2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt @@ -15,7 +15,7 @@ class PeriodicSyncWorker( ): Worker(context, workerParams) { companion object { - const val NAME = "PeriodicSync" + private const val NAME = "PeriodicSync" fun setInterval(context: Context, seconds: Long?) { val wm = WorkManager.getInstance(context) diff --git a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt index 30a8fa2f849453b952e0b449f1b2fe296d665f38..b9b1a9d240cd51568fd86a5da2be48fb0e7aed98 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt @@ -5,30 +5,116 @@ package at.bitfire.icsdroid import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NotificationManagerCompat -import at.bitfire.icsdroid.ui.NotificationUtils +import androidx.core.content.ContextCompat -class PermissionUtils(val activity: AppCompatActivity) { +object PermissionUtils { - fun registerCalendarPermissionRequestLauncher() = - activity.registerForActivityResult( + private val CALENDAR_PERMISSIONS = arrayOf( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + ) + + /** + * Checks whether the calling app has all [CALENDAR_PERMISSIONS]. + * + * @param context context to check permissions within + * @return *true* if all calendar permissions are granted; *false* otherwise + */ + fun haveCalendarPermissions(context: Context) = CALENDAR_PERMISSIONS.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + /** + * Checks whether the calling app has permission to request notifications. If the device's SDK + * level is lower than Tiramisu, always returns `true`. + * + * @param context context to check permissions within + * @return *true* if notification permissions are granted; *false* otherwise + */ + fun haveNotificationPermission(context: Context) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + else + true + + /** + * Registers for the result of the request of some permissions. + * Invoke the returned anonymous function to actually request the permissions. + * + * When all requested permissions are granted, [onGranted] is called. + * When not all requested permissions are granted, a toast is shown. + * + * @param activity The activity where to register the request launcher. + * @param permissions The permissions to be requested. + * @param toastMessage The message to show in a toast if at least one permissions was not granted. + * @param onGranted What to call when all permissions were granted. + * + * @return The request launcher for launching the request. + */ + private fun registerPermissionRequest( + activity: AppCompatActivity, + permissions: Array, + @StringRes toastMessage: Int, + onGranted: () -> Unit = {}, + ): (() -> Unit) { + val request = activity.registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - if (permissions[Manifest.permission.READ_CALENDAR] == false || - permissions[Manifest.permission.WRITE_CALENDAR] == false) { - // calendar permissions missing - Toast.makeText(activity, R.string.calendar_permissions_required, Toast.LENGTH_LONG).show() - activity.finish() - - } else if (permissions[Manifest.permission.READ_CALENDAR] == true && - permissions[Manifest.permission.WRITE_CALENDAR] == true) { - // we have calendar permissions, cancel possible notification - val nm = NotificationManagerCompat.from(activity) - nm.cancel(NotificationUtils.NOTIFY_PERMISSION) + ) { permissionsResult -> + Log.i(Constants.TAG, "Requested permissions: ${permissions.asList()}, got permissions: $permissionsResult") + if (permissions.all { requestedPermission -> permissionsResult.getOrDefault(requestedPermission, null) == true }) + // all permissions granted + onGranted() + else { + // some permissions missing + Toast.makeText(activity, toastMessage, Toast.LENGTH_LONG).show() } } + return { request.launch(permissions) } + } + + /** + * Registers a calendar permission request launcher. + * + * @param activity activity to register permission request launcher + * @param onGranted called when calendar permissions have been granted + * + * @return Call the returning function to launch the request + */ + fun registerCalendarPermissionRequest(activity: AppCompatActivity, onGranted: () -> Unit = {}) = + registerPermissionRequest( + activity, + CALENDAR_PERMISSIONS, + R.string.calendar_permissions_required, + onGranted + ) + + /** + * Registers a notification permission request launcher. + * + * @param activity activity to register permission request launcher + * @param onGranted called when calendar permissions have been granted + * + * @return Call the returning function to launch the request + */ + fun registerNotificationPermissionRequest(activity: AppCompatActivity, onGranted: () -> Unit = {}) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + registerPermissionRequest( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + R.string.notification_permissions_required, + onGranted + ) + else { + // If SDK level is not greater or equal than Tiramisu, do nothing + {} + } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt index af989e50b43780f20475bc6631c3c8df6a4c791c..9ffe788f9652e37f5ec0bc056a2efaddf6797078 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt @@ -5,70 +5,128 @@ package at.bitfire.icsdroid import android.app.PendingIntent -import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri -import android.provider.CalendarContract import android.util.Log import androidx.core.app.NotificationCompat -import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.Event -import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar -import at.bitfire.icsdroid.db.LocalEvent +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.icsdroid.calendar.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalEvent +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Subscription import at.bitfire.icsdroid.ui.EditCalendarActivity import at.bitfire.icsdroid.ui.NotificationUtils +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Trigger import okhttp3.MediaType import java.io.InputStream import java.io.InputStreamReader -import java.net.MalformedURLException - +import java.time.Duration + +/** + * Fetches the .ics for a given Webcal subscription and stores the events + * in the local calendar provider. + * + * By default, caches will be used: + * + * - for fetching a calendar by HTTP (ETag/Last-Modified), + * - for updating the local events (will only be updated when LAST-MODIFIED is newer). + * + * @param context context to work in + * @param subscription represents the subscription to be checked + * @param forceResync enforces that the calendar is fetched and all events are fully processed + * (useful when subscription settings have been changed) + */ class ProcessEventsTask( - val context: Context, - val calendar: LocalCalendar + val context: Context, + val subscription: Subscription, + val calendar: LocalCalendar, + val forceResync: Boolean ) { + private val db = AppDatabase.getInstance(context) + private val subscriptionsDao = db.subscriptionsDao() + suspend fun sync() { Thread.currentThread().contextClassLoader = context.classLoader try { - // provide iCalendar event color values to Android - AndroidCalendar.insertColors(calendar.provider, calendar.account) - processEvents() - } catch(e: Exception) { + } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't sync calendar", e) - calendar.updateStatusError(e.localizedMessage ?: e.toString()) + subscriptionsDao.updateStatusError(subscription.id, e.localizedMessage ?: e.toString()) } Log.i(Constants.TAG, "iCalendar file completely processed") } + /** + * Updates the alarms of the given event according to the [subscription]'s + * [Subscription.defaultAlarmMinutes], [Subscription.defaultAllDayAlarmMinutes] and + * [Subscription.ignoreEmbeddedAlerts] + * parameters. + * @since 20221208 + * @param event The event to update. + * @return The given [event], with the alarms updated. + */ + private fun updateAlarms(event: Event): Event = event.apply { + if (subscription.ignoreEmbeddedAlerts) { + // Remove all alerts + Log.d(Constants.TAG, "Removing all alarms from ${uid}: $this") + alarms.clear() + } + val isAllDay = DateUtils.isDate(dtStart) + val alarmMinutes = if (isAllDay) + subscription.defaultAllDayAlarmMinutes + else + subscription.defaultAlarmMinutes + if (alarmMinutes != null) { + // Add the default alarm to the event + Log.d(Constants.TAG, "Adding the default alarm to ${uid}.") + alarms.add( + // Create the new VAlarm + VAlarm.Factory().createComponent( + // Set all the properties for the alarm + PropertyList().apply { + // Set action to DISPLAY + add(Action.DISPLAY) + // Add the trigger x minutes before + val duration = Duration.ofMinutes(-alarmMinutes) + add(Trigger(duration)) + } + ) + ) + } + } + private suspend fun processEvents() { - val uri = - try { - Uri.parse(calendar.url) - } catch(e: MalformedURLException) { - Log.e(Constants.TAG, "Invalid calendar URL", e) - calendar.updateStatusError(e.localizedMessage ?: e.toString()) - return - } - Log.i(Constants.TAG, "Synchronizing $uri") + val uri = subscription.url + Log.i(Constants.TAG, "Synchronizing $uri, forceResync=$forceResync") // dismiss old notifications val notificationManager = NotificationUtils.createChannels(context) - notificationManager.cancel(calendar.id.toString(), 0) + notificationManager.cancel(subscription.id.toString(), 0) var exception: Throwable? = null - val downloader = object: CalendarFetcher(context, uri) { - override fun onSuccess(data: InputStream, contentType: MediaType?, eTag: String?, lastModified: Long?, displayName: String?) { + val downloader = object : CalendarFetcher(context, uri) { + override fun onSuccess( + data: InputStream, + contentType: MediaType?, + eTag: String?, + lastModified: Long?, + displayName: String? + ) { InputStreamReader(data, contentType?.charset() ?: Charsets.UTF_8).use { reader -> try { val events = Event.eventsFromReader(reader) - processEvents(events) + processEvents(events, forceResync) Log.i(Constants.TAG, "Calendar sync successful, ETag=$eTag, lastModified=$lastModified") - calendar.updateStatusSuccess(eTag, lastModified ?: 0L) + subscriptionsDao.updateStatusSuccess(subscription.id, eTag, lastModified) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't process events", e) exception = e @@ -78,30 +136,35 @@ class ProcessEventsTask( override fun onNotModified() { Log.i(Constants.TAG, "Calendar has not been modified since last sync") - calendar.updateStatusNotModified() + subscriptionsDao.updateStatusNotModified(subscription.id) } override fun onNewPermanentUrl(target: Uri) { super.onNewPermanentUrl(target) Log.i(Constants.TAG, "Got permanent redirect, saving new URL: $target") - calendar.updateUrl(target.toString()) + subscriptionsDao.updateUrl(subscription.id, target) } override fun onError(error: Exception) { Log.w(Constants.TAG, "Sync error", error) exception = error } - } - CalendarCredentials(context).get(calendar).let { (username, password) -> - downloader.username = username - downloader.password = password } - if (calendar.eTag != null) - downloader.ifNoneMatch = calendar.eTag - if (calendar.lastModified != 0L) - downloader.ifModifiedSince = calendar.lastModified + // Get the credentials for the given subscription from the database + AppDatabase.getInstance(context) + .credentialsDao() + .getBySubscriptionId(subscription.id) + ?.let { (_, username, password) -> + downloader.username = username + downloader.password = password + } + + if (subscription.eTag != null && !forceResync) + downloader.ifNoneMatch = subscription.eTag + if (subscription.lastModified != 0L && !forceResync) + downloader.ifModifiedSince = subscription.lastModified downloader.fetch() @@ -109,33 +172,44 @@ class ProcessEventsTask( val message = ex.localizedMessage ?: ex.message ?: ex.toString() val errorIntent = Intent(context, EditCalendarActivity::class.java) - errorIntent.data = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendar.id) - errorIntent.putExtra(EditCalendarActivity.ERROR_MESSAGE, message) - errorIntent.putExtra(EditCalendarActivity.THROWABLE, ex) + errorIntent.putExtra(EditCalendarActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) + errorIntent.putExtra(EditCalendarActivity.EXTRA_ERROR_MESSAGE, message) + errorIntent.putExtra(EditCalendarActivity.EXTRA_THROWABLE, ex) val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC) - .setSmallIcon(R.drawable.ic_sync_problem_white) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setGroup(context.getString(R.string.app_name)) - .setContentTitle(context.getString(R.string.sync_error_title)) - .setContentText(message) - .setSubText(calendar.displayName) - .setContentIntent(PendingIntent.getActivity(context, 0, errorIntent, PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()) - .setOnlyAlertOnce(true) - calendar.color?.let { notification.color = it } - notificationManager.notify(calendar.id.toString(), 0, notification.build()) - - calendar.updateStatusError(message) + .setSmallIcon(R.drawable.ic_sync_problem_white) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setGroup(context.getString(R.string.app_name)) + .setContentTitle(context.getString(R.string.sync_error_title)) + .setContentText(message) + .setSubText(subscription.displayName) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + errorIntent, + PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat + ) + ) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()) + .setOnlyAlertOnce(true) + subscription.color?.let { notification.color = it } + notificationManager.notify(subscription.id.toString(), 0, notification.build()) + + subscriptionsDao.updateStatusError(subscription.id, message) } } - private fun processEvents(events: List) { - Log.i(Constants.TAG, "Processing ${events.size} events") + private fun processEvents(events: List, ignoreLastModified: Boolean) { + Log.i( + Constants.TAG, + "Processing ${events.size} events (ignoreLastModified=$ignoreLastModified)" + ) val uids = HashSet(events.size) - for (event in events) { + for (ev in events) { + val event = updateAlarms(ev) val uid = event.uid!! Log.d(Constants.TAG, "Found VEVENT: $uid") uids += uid @@ -144,10 +218,10 @@ class ProcessEventsTask( if (localEvents.isEmpty()) { Log.d(Constants.TAG, "$uid not in local calendar, adding") LocalEvent(calendar, event).add() - } else { val localEvent = localEvents.first() - var lastModified = event.lastModified + + var lastModified = if (ignoreLastModified) null else event.lastModified Log.d(Constants.TAG, "$uid already in local calendar, lastModified = $lastModified") if (lastModified != null) { diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt b/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt index 349a6a48f8e8eeee9b2550a4c25e5653c4e5ddea..c089eccc9adb41058087280159c4637197553ee4 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt @@ -5,16 +5,13 @@ package at.bitfire.icsdroid import android.accounts.Account -import android.app.PendingIntent import android.content.* import android.os.Bundle -import androidx.core.app.NotificationCompat import androidx.work.WorkManager -import at.bitfire.icsdroid.ui.CalendarListActivity import at.bitfire.icsdroid.ui.NotificationUtils class SyncAdapter( - context: Context + context: Context ): AbstractThreadedSyncAdapter(context, false) { override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { @@ -28,18 +25,11 @@ class SyncAdapter( wm.cancelUniqueWork(SyncWorker.NAME) } + /** + * Called by the sync framework when we don't have calendar permissions. + */ override fun onSecurityException(account: Account?, extras: Bundle?, authority: String?, syncResult: SyncResult?) { - val nm = NotificationUtils.createChannels(context) - val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC) - .setSmallIcon(R.drawable.ic_sync_problem_white) - .setContentTitle(context.getString(R.string.sync_permission_required)) - .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, CalendarListActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) - .setAutoCancel(true) - .setLocalOnly(true) - .build() - nm.notify(NotificationUtils.NOTIFY_PERMISSION, notification) + NotificationUtils.showCalendarPermissionNotification(context) } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 7b047ca738a4d9a97c5aa81173b3a6b1ca8799a4..156c306a4f17d6699a3fe15135e27d912e753161 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -4,46 +4,66 @@ package at.bitfire.icsdroid -import android.accounts.Account -import android.annotation.SuppressLint import android.content.ContentProviderClient +import android.content.ContentUris import android.content.Context -import android.provider.CalendarContract import android.util.Log import androidx.work.* -import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat -import at.bitfire.icsdroid.db.LocalCalendar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import at.bitfire.icsdroid.Constants.TAG +import at.bitfire.icsdroid.calendar.LocalCalendar +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.CalendarCredentials +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription +import at.bitfire.icsdroid.ui.NotificationUtils class SyncWorker( - context: Context, - workerParams: WorkerParameters + context: Context, + workerParams: WorkerParameters ): CoroutineWorker(context, workerParams) { companion object { + /** The name of the worker. Tags the unique work. */ const val NAME = "SyncWorker" + /** + * An input data (Boolean) for the Worker that tells whether the synchronization should be performed + * without taking into account the current network condition. + */ + const val FORCE_RESYNC = "forceResync" + + /** + * An input data (Boolean) for the Worker that tells if only migration should be performed, without + * fetching data. + */ + const val ONLY_MIGRATE = "onlyMigration" /** * Enqueues a sync job for immediate execution. If the sync is forced, * the "requires network connection" constraint won't be set. * - * @param context required for managing work - * @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint + * @param context required for managing work + * @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint + * @param forceResync *true* ignores all locally stored data and fetched everything from the server again + * @param onlyMigrate *true* only runs synchronization, without fetching data. */ - fun run(context: Context, force: Boolean = false) { + fun run(context: Context, force: Boolean = false, forceResync: Boolean = false, onlyMigrate: Boolean = false) { val request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + FORCE_RESYNC to forceResync, + ONLY_MIGRATE to onlyMigrate, + ) + ) - val policy: ExistingWorkPolicy - if (force) { - Log.i(Constants.TAG, "Manual sync, ignoring network condition") + val policy: ExistingWorkPolicy = if (force) { + Log.i(TAG, "Manual sync, ignoring network condition") // overwrite existing syncs (which may have unwanted constraints) - policy = ExistingWorkPolicy.REPLACE - + ExistingWorkPolicy.REPLACE } else { // regular sync, requires network request.setConstraints(Constraints.Builder() @@ -51,7 +71,7 @@ class SyncWorker( .build()) // don't overwrite previous syncs (whether regular or manual) - policy = ExistingWorkPolicy.KEEP + ExistingWorkPolicy.KEEP } WorkManager.getInstance(context) @@ -60,39 +80,145 @@ class SyncWorker( } fun liveStatus(context: Context) = - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(NAME) + WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(NAME) } + private val database = AppDatabase.getInstance(applicationContext) + private val subscriptionsDao = database.subscriptionsDao() + private val credentialsDao = database.credentialsDao() + + private val account = AppAccount.get(applicationContext) + lateinit var provider: ContentProviderClient + + private var forceReSync: Boolean = false - @SuppressLint("Recycle") override suspend fun doWork(): Result { - applicationContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { providerClient -> + forceReSync = inputData.getBoolean(FORCE_RESYNC, false) + val onlyMigrate = inputData.getBoolean(ONLY_MIGRATE, false) + Log.i(TAG, "Synchronizing (forceReSync=$forceReSync,onlyMigrate=$onlyMigrate)") + + provider = try { - return withContext(Dispatchers.Default) { - performSync(AppAccount.get(applicationContext), providerClient) - } - } finally { - providerClient.closeCompat() + LocalCalendar.getCalendarProvider(applicationContext) + } catch (e: SecurityException) { + NotificationUtils.showCalendarPermissionNotification(applicationContext) + return Result.failure() } - } - return Result.failure() - } - private suspend fun performSync(account: Account, provider: ContentProviderClient): Result { - Log.i(Constants.TAG, "Synchronizing ${account.name}") try { - LocalCalendar.findAll(account, provider) - .filter { it.isSynced } - .forEach { ProcessEventsTask(applicationContext, it).sync() } + // migrate old calendar-based subscriptions to database + migrateLegacyCalendars() + + // Do not synchronize if onlyMigrate is true + if (onlyMigrate) return Result.success() - } catch (e: CalendarStorageException) { - Log.e(Constants.TAG, "Calendar storage exception", e) + // update local calendars according to the subscriptions + updateLocalCalendars() + + // provide iCalendar event color values to Android + val account = AppAccount.get(applicationContext) + AndroidCalendar.insertColors(provider, account) + + // sync local calendars + for (subscription in subscriptionsDao.getAll()) { + // Make sure the subscription has a matching calendar + subscription.calendarId ?: continue + val calendar = LocalCalendar.findById(account, provider, subscription.calendarId) + ProcessEventsTask(applicationContext, subscription, calendar, forceReSync).sync() + } } catch (e: InterruptedException) { - Log.e(Constants.TAG, "Thread interrupted", e) + Log.e(TAG, "Thread interrupted", e) + return Result.retry() + } finally { + provider.closeCompat() } return Result.success() } -} + /** + * Migrates all the legacy calendar-based subscriptions to the database. Performs these steps: + * + * 1. Searches for all the calendars created + * 2. Checks that those calendars have a matching [Subscription] in the database. + * 3. If there's no matching [Subscription], create it. + */ + private fun migrateLegacyCalendars() { + @Suppress("DEPRECATION") + val legacyCredentials by lazy { CalendarCredentials(applicationContext) } + + // if there's a provider available, get all the calendars available in the system + for (calendar in LocalCalendar.findUnmanaged(account, provider)) { + Log.i(TAG, "Found unmanaged (<= v2.1.1) calendar ${calendar.id}, migrating") + val url = calendar.url ?: continue + + // Special case v2.1: it created subscriptions, but did not set the COLUMN_MANAGED_BY_DB flag. + val subscription = subscriptionsDao.getByUrl(url) + if (subscription != null) { + // So we already have a subscription and only net to set its calendar_id. + Log.i(TAG, "Migrating from v2.1: updating subscription ${subscription.id} with calendar ID") + subscriptionsDao.updateCalendarId(subscription.id, calendar.id) + + } else { + // before v2.1: if there's no subscription with the same URL + val newSubscription = Subscription.fromLegacyCalendar(calendar) + Log.i(TAG, "Migrating from < v2.1: creating subscription $newSubscription") + val subscriptionId = subscriptionsDao.add(newSubscription) + + // migrate credentials, too (if available) + val (legacyUsername, legacyPassword) = legacyCredentials.get(calendar) + if (legacyUsername != null && legacyPassword != null) + credentialsDao.create(Credential(subscriptionId, legacyUsername, legacyPassword)) + } + + // set MANAGED_BY_DB=1 so that the calendar won't be migrated anymore + calendar.setManagedByDB() + } + } + + /** + * Updates the local calendars according to the available [Subscription]s. A local calendar is + * + * - created if there's a [Subscription] without calendar, + * - updated (e.g. display name) if there's a [Subscription] for this calendar, + * - deleted if there's no [Subscription] for this calendar. + */ + private fun updateLocalCalendars() { + // subscriptions from DB + val subscriptions = subscriptionsDao.getAll() + + // local calendars from provider as Map: + val calendars = LocalCalendar.findManaged(account, provider).associateBy { it.id }.toMutableMap() + + // synchronize them + for (subscription in subscriptions) { + val calendarId = subscription.calendarId + val calendar = calendars.remove(calendarId) + // note that calendar might still be null even if calendarId is not null, + // for instance when the calendar has been removed from the system + + if (calendar == null) { + // no local calendar yet, create it + Log.d(TAG, "Creating local calendar from subscription #${subscription.id}") + // create local calendar + val uri = AndroidCalendar.create(account, provider, subscription.toCalendarProperties()) + // update calendar ID in DB + val newCalendarId = ContentUris.parseId(uri) + subscriptionsDao.updateCalendarId(subscription.id, newCalendarId) + + } else { + // local calendar already existing, update accordingly + Log.d(TAG, "Updating local calendar #$calendarId from subscription") + calendar.update(subscription.toCalendarProperties()) + } + } + + // remove remaining calendars + for (calendar in calendars.values) { + Log.d(TAG, "Removing local calendar #${calendar.id} without subscription") + calendar.delete() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/UriUtils.kt b/app/src/main/java/at/bitfire/icsdroid/UriUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..80267d3555cbcccb5f930d400e30c96408b19f5a --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/UriUtils.kt @@ -0,0 +1,35 @@ +package at.bitfire.icsdroid + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast + +object UriUtils { + /** + * Starts the [Intent.ACTION_VIEW] intent with the given URL, if possible. + * If the intent can't be resolved (for instance, because there is no browser + * installed), this method does nothing. + * + * @param toastInstallBrowser whether to show "Please install a browser" toast when + * the Intent could not be resolved + * + * @return true on success, false if the Intent could not be resolved (for instance, because + * there is no user agent installed) + */ + fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean { + val intent = Intent(action, uri) + try { + context.startActivity(intent) + return true + } catch (e: ActivityNotFoundException) { + // no browser available + } + + if (toastInstallBrowser) + Toast.makeText(context, R.string.install_browser, Toast.LENGTH_LONG).show() + + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/calendar/LocalCalendar.kt b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalCalendar.kt new file mode 100644 index 0000000000000000000000000000000000000000..2f10ecd8a27c52c9d2f0fa7916055951fcc38ac8 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalCalendar.kt @@ -0,0 +1,181 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.calendar + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.os.RemoteException +import android.provider.CalendarContract +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidCalendarFactory +import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter + +class LocalCalendar private constructor( + account: Account, + provider: ContentProviderClient, + id: Long +) : AndroidCalendar(account, provider, LocalEvent.Factory, id) { + + companion object { + + const val DEFAULT_COLOR = 0xFF2F80C7.toInt() + + @Deprecated("Use Subscription table") + const val COLUMN_ETAG = Calendars.CAL_SYNC1 + @Deprecated("Use Subscription table") + const val COLUMN_LAST_MODIFIED = Calendars.CAL_SYNC4 + @Deprecated("Use Subscription table") + const val COLUMN_LAST_SYNC = Calendars.CAL_SYNC5 + @Deprecated("Use Subscription table") + const val COLUMN_ERROR_MESSAGE = Calendars.CAL_SYNC6 + + /** + * Stores if the calendar's embedded alerts should be ignored. + */ + @Deprecated("Use Subscription table") + const val COLUMN_IGNORE_EMBEDDED = Calendars.CAL_SYNC8 + + /** + * Stores the default alarm to set to all events in the given calendar. + */ + @Deprecated("Use Subscription table") + const val COLUMN_DEFAULT_ALARM = Calendars.CAL_SYNC7 + + /** + * Whether this calendar is managed by the [at.bitfire.icsdroid.db.entity.Subscription] table. + * All calendars should be set to `1` except legacy calendars from the time before we had a database. + * A `null` value should be considered as _this calendar has not been migrated to the database yet_. + */ + const val COLUMN_MANAGED_BY_DB = Calendars.CAL_SYNC9 + + /** + * Gets the calendar provider for a given context. + * The caller (you) is responsible for closing the client! + * + * @throws CalendarStorageException if the calendar provider is not available + * @throws SecurityException if permissions for accessing the calendar are not granted + */ + fun getCalendarProvider(context: Context): ContentProviderClient = + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) ?: + throw CalendarStorageException("Calendar provider not available") + + + // CRUD methods + + fun findById(account: Account, provider: ContentProviderClient, id: Long) = + findByID(account, provider, Factory, id) + + fun findManaged(account: Account, provider: ContentProviderClient) = + find(account, provider, Factory, "$COLUMN_MANAGED_BY_DB IS NOT NULL", null) + + fun findUnmanaged(account: Account, provider: ContentProviderClient) = + find(account, provider, Factory, "$COLUMN_MANAGED_BY_DB IS NULL", null) + + } + + /** URL of iCalendar file */ + @Deprecated("Use Subscription table") + var url: String? = null + /** iCalendar ETag at last successful sync */ + @Deprecated("Use Subscription table") + var eTag: String? = null + + /** iCalendar Last-Modified at last successful sync (or 0 for none) */ + @Deprecated("Use Subscription table") + var lastModified = 0L + /** time of last sync (0 if none) */ + @Deprecated("Use Subscription table") + var lastSync = 0L + /** error message (HTTP status or exception name) of last sync (or null) */ + @Deprecated("Use Subscription table") + var errorMessage: String? = null + + /** Setting: whether to ignore alarms embedded in the Webcal */ + @Deprecated("Use Subscription table") + var ignoreEmbeddedAlerts: Boolean? = null + /** Setting: Shall a default alarm be added to every event in the calendar? If yes, this + * field contains the minutes before the event. If no, it is *null*. */ + @Deprecated("Use Subscription table") + var defaultAlarmMinutes: Long? = null + + + override fun populate(info: ContentValues) { + super.populate(info) + url = info.getAsString(Calendars.NAME) + + eTag = info.getAsString(COLUMN_ETAG) + info.getAsLong(COLUMN_LAST_MODIFIED)?.let { lastModified = it } + + info.getAsLong(COLUMN_LAST_SYNC)?.let { lastSync = it } + errorMessage = info.getAsString(COLUMN_ERROR_MESSAGE) + + info.getAsBoolean(COLUMN_IGNORE_EMBEDDED)?.let { ignoreEmbeddedAlerts = it } + info.getAsLong(COLUMN_DEFAULT_ALARM)?.let { defaultAlarmMinutes = it } + } + + + fun queryByUID(uid: String) = + queryEvents("${Events._SYNC_ID}=?", arrayOf(uid)) + + fun retainByUID(uids: MutableSet): Int { + var deleted = 0 + try { + provider.query( + Events.CONTENT_URI.asSyncAdapter(account), + arrayOf(Events._ID, Events._SYNC_ID, Events.ORIGINAL_SYNC_ID), + "${Events.CALENDAR_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS NULL", arrayOf(id.toString()), null + )?.use { row -> + while (row.moveToNext()) { + val eventId = row.getLong(0) + val syncId = row.getString(1) + if (!uids.contains(syncId)) { + provider.delete(ContentUris.withAppendedId(Events.CONTENT_URI, eventId).asSyncAdapter(account), null, null) + deleted++ + + uids -= syncId + } + } + } + return deleted + } catch (e: RemoteException) { + throw CalendarStorageException("Couldn't delete local events") + } + } + + fun isManagedByDB(): Boolean { + provider.query(calendarSyncURI(), arrayOf(COLUMN_MANAGED_BY_DB), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + return !cursor.isNull(0) + } + + // row doesn't exist, assume default value + return true + } + + /** + * Updates the entry in the provider to set [COLUMN_MANAGED_BY_DB] to 1. + * The calendar is then marked as _managed by the database_ and won't be migrated anymore, for instance. + */ + fun setManagedByDB() { + val values = ContentValues(1) + values.put(COLUMN_MANAGED_BY_DB, 1) + provider.update(calendarSyncURI(), values, null, null) + } + + + object Factory : AndroidCalendarFactory { + + override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) = + LocalCalendar(account, provider, id) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/LocalEvent.kt b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalEvent.kt similarity index 98% rename from app/src/main/java/at/bitfire/icsdroid/db/LocalEvent.kt rename to app/src/main/java/at/bitfire/icsdroid/calendar/LocalEvent.kt index 7cfb4d397ed633622e5baa824c680b195a3c9a54..a58321c1b69b2db96eda3c96453edd413795e922 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/LocalEvent.kt +++ b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalEvent.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.icsdroid.db +package at.bitfire.icsdroid.calendar import android.content.ContentValues import android.provider.CalendarContract diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt new file mode 100644 index 0000000000000000000000000000000000000000..af9aa729dfc4ad596006fbe2c76dc8fbeb985f28 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -0,0 +1,90 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.db + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.db.AppDatabase.Companion.getInstance +import at.bitfire.icsdroid.db.dao.CredentialsDao +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription + +/** + * The database for storing all the ICSx5 subscriptions and other data. Use [getInstance] for getting access to the database. + */ +@TypeConverters(Converters::class) +@Database( + entities = [Subscription::class, Credential::class], + version = 3, + autoMigrations = [ + AutoMigration ( + from = 1, + to = 2 + ), + AutoMigration ( + from = 2, + to = 3 + ) + ] +) +abstract class AppDatabase : RoomDatabase() { + + companion object { + @Volatile + private var instance: AppDatabase? = null + + /** + * This function is only intended to be used by tests, use [getInstance], it initializes + * the instance automatically. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun setInstance(instance: AppDatabase?) { + this.instance = instance + } + + /** + * Gets or instantiates the database singleton. Thread-safe. + * @param context The application's context, required to create the database. + */ + fun getInstance(context: Context): AppDatabase { + // if we have an existing instance, return it + instance?.let { + return it + } + + // multiple threads might access this code at once, so synchronize it + synchronized(AppDatabase) { + // another thread might just have created an instance + instance?.let { + return it + } + + // create a new instance and save it + val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "icsx5") + .fallbackToDestructiveMigration() + .addCallback(object : Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + SyncWorker.run(context, onlyMigrate = true) + } + }) + .build() + instance = db + return db + } + } + } + + abstract fun subscriptionsDao(): SubscriptionsDao + abstract fun credentialsDao(): CredentialsDao + +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt b/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt index 05a94ad8dde567a63eaed4e7322724326222e388..b05b1ff72549331b44c1157e504cb0454f08be3b 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt @@ -5,7 +5,15 @@ package at.bitfire.icsdroid.db import android.content.Context - +import at.bitfire.icsdroid.calendar.LocalCalendar + +@Deprecated( + "Use Room's Credentials from database.", + replaceWith = ReplaceWith( + "CredentialsDao.getInstance(context)", + "at.bitfire.icsdroid.db.AppDatabase" + ), +) class CalendarCredentials(context: Context) { companion object { diff --git a/app/src/main/java/at/bitfire/icsdroid/db/Converters.kt b/app/src/main/java/at/bitfire/icsdroid/db/Converters.kt new file mode 100644 index 0000000000000000000000000000000000000000..9a999bda15ddfcfa873ff99c7a04128d8fa7510b --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/Converters.kt @@ -0,0 +1,17 @@ +package at.bitfire.icsdroid.db + +import android.net.Uri +import androidx.room.TypeConverter + +/** + * Provides converters for complex types in the Room DB. + */ +class Converters { + /** Converts an [Uri] to a [String]. */ + @TypeConverter + fun fromUri(value: Uri?): String? = value?.toString() + + /** Converts a [String] to an [Uri]. */ + @TypeConverter + fun toUri(value: String?): Uri? = value?.let { Uri.parse(it) } +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt b/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt deleted file mode 100644 index 5ec3b2c77734004a9a1cae0d862855ff504529d3..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt +++ /dev/null @@ -1,138 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.db - -import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentUris -import android.content.ContentValues -import android.os.RemoteException -import android.provider.CalendarContract.Calendars -import android.provider.CalendarContract.Events -import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.AndroidCalendarFactory -import at.bitfire.ical4android.CalendarStorageException -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter - -class LocalCalendar private constructor( - account: Account, - provider: ContentProviderClient, - id: Long -): AndroidCalendar(account, provider, LocalEvent.Factory, id) { - - companion object { - - const val DEFAULT_COLOR = 0xFF2F80C7.toInt() - - const val COLUMN_ETAG = Calendars.CAL_SYNC1 - const val COLUMN_LAST_MODIFIED = Calendars.CAL_SYNC4 - const val COLUMN_LAST_SYNC = Calendars.CAL_SYNC5 - const val COLUMN_ERROR_MESSAGE = Calendars.CAL_SYNC6 - - fun findById(account: Account, provider: ContentProviderClient, id: Long) = - findByID(account, provider, Factory, id) - - fun findAll(account: Account, provider: ContentProviderClient) = - find(account, provider, Factory, null, null) - - } - - var url: String? = null // URL of iCalendar file - var eTag: String? = null // iCalendar ETag at last successful sync - - var lastModified = 0L // iCalendar Last-Modified at last successful sync (or 0 for none) - var lastSync = 0L // time of last sync (0 if none) - var errorMessage: String? = null // error message (HTTP status or exception name) of last sync (or null) - - - override fun populate(info: ContentValues) { - super.populate(info) - url = info.getAsString(Calendars.NAME) - - eTag = info.getAsString(COLUMN_ETAG) - info.getAsLong(COLUMN_LAST_MODIFIED)?.let { lastModified = it } - - info.getAsLong(COLUMN_LAST_SYNC)?.let { lastSync = it } - errorMessage = info.getAsString(COLUMN_ERROR_MESSAGE) - } - - fun updateStatusSuccess(eTag: String?, lastModified: Long) { - this.eTag = eTag - this.lastModified = lastModified - lastSync = System.currentTimeMillis() - - val values = ContentValues(4) - values.put(COLUMN_ETAG, eTag) - values.put(COLUMN_LAST_MODIFIED, lastModified) - values.put(COLUMN_LAST_SYNC, lastSync) - values.putNull(COLUMN_ERROR_MESSAGE) - update(values) - } - - fun updateStatusNotModified() { - lastSync = System.currentTimeMillis() - - val values = ContentValues(1) - values.put(COLUMN_LAST_SYNC, lastSync) - update(values) - } - - fun updateStatusError(message: String) { - eTag = null - lastModified = 0 - lastSync = System.currentTimeMillis() - errorMessage = message - - val values = ContentValues(4) - values.putNull(COLUMN_ETAG) - values.putNull(COLUMN_LAST_MODIFIED) - values.put(COLUMN_LAST_SYNC, lastSync) - values.put(COLUMN_ERROR_MESSAGE, message) - update(values) - } - - fun updateUrl(url: String) { - this.url = url - - val values = ContentValues(1) - values.put(Calendars.NAME, url) - update(values) - } - - fun queryByUID(uid: String) = - queryEvents("${Events._SYNC_ID}=?", arrayOf(uid)) - - fun retainByUID(uids: MutableSet): Int { - var deleted = 0 - try { - provider.query(Events.CONTENT_URI.asSyncAdapter(account), - arrayOf(Events._ID, Events._SYNC_ID, Events.ORIGINAL_SYNC_ID), - "${Events.CALENDAR_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS NULL", arrayOf(id.toString()), null)?.use { row -> - while (row.moveToNext()) { - val eventId = row.getLong(0) - val syncId = row.getString(1) - if (!uids.contains(syncId)) { - provider.delete(ContentUris.withAppendedId(Events.CONTENT_URI, eventId).asSyncAdapter(account), null, null) - deleted++ - - uids -= syncId - } - } - } - return deleted - } catch(e: RemoteException) { - throw CalendarStorageException("Couldn't delete local events") - } - } - - - object Factory: AndroidCalendarFactory { - - override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) = - LocalCalendar(account, provider, id) - - } - -} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..5fb233baa42f894cf08c9db8dab13f23f6d4d4f9 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt @@ -0,0 +1,24 @@ +package at.bitfire.icsdroid.db.dao + +import androidx.room.* +import at.bitfire.icsdroid.db.entity.Credential + +@Dao +interface CredentialsDao { + + @Query("SELECT * FROM credentials WHERE subscriptionId=:subscriptionId") + fun getBySubscriptionId(subscriptionId: Long): Credential? + + @Insert + fun create(credential: Credential) + + @Upsert + fun upsert(credential: Credential) + + @Query("DELETE FROM credentials WHERE subscriptionId=:subscriptionId") + fun removeBySubscriptionId(subscriptionId: Long) + + @Update + fun update(credential: Credential) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..79a7c9f40f2dff696f9f3669883b4cca20f5ceb0 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt @@ -0,0 +1,67 @@ +package at.bitfire.icsdroid.db.dao + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.room.* +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription + +@Dao +interface SubscriptionsDao { + + @Insert + fun add(subscription: Subscription): Long + + @Delete + fun delete(vararg subscriptions: Subscription) + + @Query("SELECT * FROM subscriptions") + fun getAllLive(): LiveData> + + @Query("SELECT * FROM subscriptions") + fun getAll(): List + + @Query("SELECT * FROM subscriptions WHERE id=:id") + fun getById(id: Long): Subscription? + + @Query("SELECT * FROM subscriptions WHERE calendarId=:calendarId") + fun getByCalendarId(calendarId: Long?): Subscription? + + @Query("SELECT * FROM subscriptions WHERE url=:url") + fun getByUrl(url: String): Subscription? + + @Query("SELECT * FROM subscriptions WHERE id=:id") + fun getWithCredentialsByIdLive(id: Long): LiveData + + @Query("SELECT errorMessage FROM subscriptions WHERE id=:id") + fun getErrorMessageLive(id: Long): LiveData + + @Update + fun update(subscription: Subscription) + + @Query("UPDATE subscriptions SET calendarId=:calendarId WHERE id=:id") + fun updateCalendarId(id: Long, calendarId: Long?) + + @Query("UPDATE subscriptions SET lastSync=:lastSync WHERE id=:id") + fun updateStatusNotModified(id: Long, lastSync: Long = System.currentTimeMillis()) + + @Query("UPDATE subscriptions SET eTag=:eTag, lastModified=:lastModified, lastSync=:lastSync, errorMessage=null WHERE id=:id") + fun updateStatusSuccess(id: Long, eTag: String?, lastModified: Long?, lastSync: Long = System.currentTimeMillis()) + + @Query("UPDATE subscriptions SET errorMessage=:message WHERE id=:id") + fun updateStatusError(id: Long, message: String?) + + @Query("UPDATE subscriptions SET url=:url WHERE id=:id") + fun updateUrl(id: Long, url: Uri) + + + data class SubscriptionWithCredential( + @Embedded val subscription: Subscription, + @Relation( + parentColumn = "id", + entityColumn = "subscriptionId" + ) + val credential: Credential? + ) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt new file mode 100644 index 0000000000000000000000000000000000000000..db59d4d63db2606d34c3f403224c2c2b2599706c --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt @@ -0,0 +1,20 @@ +package at.bitfire.icsdroid.db.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +/** + * Stores the credentials to be used with a specific subscription. + */ +@Entity( + tableName = "credentials", + foreignKeys = [ + ForeignKey(entity = Subscription::class, parentColumns = ["id"], childColumns = ["subscriptionId"], onDelete = ForeignKey.CASCADE), + ] +) +data class Credential( + @PrimaryKey val subscriptionId: Long, + val username: String, + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt new file mode 100644 index 0000000000000000000000000000000000000000..85e850d8a8591c9ff85d86f5278585dac23ed194 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -0,0 +1,85 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.db.entity + +import android.net.Uri +import android.provider.CalendarContract.Calendars +import androidx.core.content.contentValuesOf +import androidx.room.Entity +import androidx.room.PrimaryKey +import at.bitfire.icsdroid.calendar.LocalCalendar + +/** + * Represents the storage of a subscription the user has made. + */ +@Entity(tableName = "subscriptions") +data class Subscription( + /** The id of the subscription in the database. */ + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + /** The id of the subscription in the system's database */ + val calendarId: Long? = null, + /** URL of iCalendar file */ + val url: Uri, + /** ETag at last successful sync */ + val eTag: String? = null, + + /** display name of the subscription */ + val displayName: String, + + /** when the remote resource was last modified, according to its source (timestamp) */ + val lastModified: Long? = null, + /** timestamp of last sync */ + val lastSync: Long? = null, + /** error message (HTTP status or exception name) of last sync (or null for _no error_) */ + val errorMessage: String? = null, + + /** setting: whether to ignore alarms embedded in the Webcal */ + val ignoreEmbeddedAlerts: Boolean = false, + /** setting: Shall a default alarm be added to every event in the calendar? If yes, this field contains the minutes before the event. If no, it is `null`. */ + val defaultAlarmMinutes: Long? = null, + /** setting: Shall a default alarm be added to every all-day event in the calendar? If yes, this field contains the minutes before the event. If no, it is `null`. */ + val defaultAllDayAlarmMinutes: Long? = null, + + /** The color that represents the subscription. */ + val color: Int? = null +) { + companion object { + /** + * Converts a [LocalCalendar] to a [Subscription] data object. + * Must only be used for migrating legacy calendars. + * + * @param calendar The legacy calendar to create the subscription from. + * @return A new [Subscription] that has the contents of [calendar]. + */ + fun fromLegacyCalendar(calendar: LocalCalendar) = + Subscription( + calendarId = calendar.id, + url = Uri.parse(calendar.url ?: "https://invalid-url"), + eTag = calendar.eTag, + displayName = calendar.displayName ?: calendar.id.toString(), + lastModified = calendar.lastModified, + lastSync = calendar.lastSync, + errorMessage = calendar.errorMessage, + ignoreEmbeddedAlerts = calendar.ignoreEmbeddedAlerts ?: false, + defaultAlarmMinutes = calendar.defaultAlarmMinutes, + color = calendar.color + ) + + } + + /** + * Converts this subscription's properties to [android.content.ContentValues] that can be + * passed to the calendar provider in order to create/update the local calendar. + */ + fun toCalendarProperties() = contentValuesOf( + Calendars.NAME to url.toString(), + Calendars.CALENDAR_DISPLAY_NAME to displayName, + Calendars.CALENDAR_COLOR to color, + Calendars.CALENDAR_ACCESS_LEVEL to Calendars.CAL_ACCESS_READ, + Calendars.SYNC_EVENTS to 1, + LocalCalendar.COLUMN_MANAGED_BY_DB to 1 + ) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt index 3978b4840e71ac5662dd585afc348518fc05d80e..beb9c661a2c1b771e357d3c65787ac8f3ee9e3e7 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt @@ -4,14 +4,10 @@ package at.bitfire.icsdroid.ui -import android.Manifest -import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat -import at.bitfire.icsdroid.PermissionUtils -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalCalendar class AddCalendarActivity: AppCompatActivity() { @@ -20,7 +16,7 @@ class AddCalendarActivity: AppCompatActivity() { const val EXTRA_COLOR = "color" } - private val titleColorModel by viewModels() + private val subscriptionSettingsModel by viewModels() override fun onCreate(inState: Bundle?) { @@ -28,11 +24,6 @@ class AddCalendarActivity: AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) - val calendarPermissionRequestLauncher = PermissionUtils(this).registerCalendarPermissionRequestLauncher() - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED) - calendarPermissionRequestLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) - if (inState == null) { supportFragmentManager .beginTransaction() @@ -41,13 +32,13 @@ class AddCalendarActivity: AppCompatActivity() { intent?.apply { data?.let { uri -> - titleColorModel.url.value = uri.toString() + subscriptionSettingsModel.url.value = uri.toString() } getStringExtra(EXTRA_TITLE)?.let { - titleColorModel.title.value = it + subscriptionSettingsModel.title.value = it } if (hasExtra(EXTRA_COLOR)) - titleColorModel.color.value = getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) + subscriptionSettingsModel.color.value = getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) } } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index 7ca26664f79040337275422068620c3ca15cdd60..caac2416b50112120605ee4ecea89bfe301ef95f 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -4,45 +4,67 @@ package at.bitfire.icsdroid.ui -import android.content.ContentProviderClient -import android.content.ContentUris -import android.content.ContentValues +import android.app.Application +import android.net.Uri import android.os.Bundle -import android.provider.CalendarContract -import android.provider.CalendarContract.Calendars import android.util.Log import android.view.* import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat -import at.bitfire.icsdroid.AppAccount +import androidx.lifecycle.viewModelScope import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class AddCalendarDetailsFragment: Fragment() { - private val titleColorModel by activityViewModels() + private val subscriptionSettingsModel by activityViewModels() private val credentialsModel by activityViewModels() + private val model by activityViewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val invalidateOptionsMenu = Observer { + val invalidateOptionsMenu = Observer { requireActivity().invalidateOptionsMenu() } - titleColorModel.title.observe(this, invalidateOptionsMenu) - titleColorModel.color.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.title.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.color.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.ignoreAlerts.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.defaultAlarmMinutes.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.defaultAllDayAlarmMinutes.observe(this, invalidateOptionsMenu) + + // Set the default value to null so that the visibility of the summary is updated + subscriptionSettingsModel.defaultAlarmMinutes.value = null + subscriptionSettingsModel.defaultAllDayAlarmMinutes.value = null } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { val v = inflater.inflate(R.layout.add_calendar_details, container, false) setHasOptionsMenu(true) + // Handle status changes + model.success.observe(viewLifecycleOwner) { success -> + if (success) { + // success, show notification and close activity + Toast.makeText(requireActivity(), requireActivity().getString(R.string.add_calendar_created),Toast.LENGTH_LONG).show() + + requireActivity().finish() + } + } + model.errorMessage.observe(viewLifecycleOwner) { message -> + Toast.makeText(requireActivity(), message, Toast.LENGTH_LONG).show() + } + return v } @@ -52,51 +74,72 @@ class AddCalendarDetailsFragment: Fragment() { override fun onPrepareOptionsMenu(menu: Menu) { val itemGo = menu.findItem(R.id.create_calendar) - itemGo.isEnabled = !titleColorModel.title.value.isNullOrBlank() + itemGo.isEnabled = !subscriptionSettingsModel.title.value.isNullOrBlank() } override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == R.id.create_calendar) { - if (createCalendar()) - requireActivity().finish() - true - } else - false - - - private fun createCalendar(): Boolean { - val account = AppAccount.get(requireActivity()) - - val calInfo = ContentValues(9) - calInfo.put(Calendars.ACCOUNT_NAME, account.name) - calInfo.put(Calendars.ACCOUNT_TYPE, account.type) - calInfo.put(Calendars.NAME, titleColorModel.url.value) - calInfo.put(Calendars.CALENDAR_DISPLAY_NAME, titleColorModel.title.value) - calInfo.put(Calendars.CALENDAR_COLOR, titleColorModel.color.value) - calInfo.put(Calendars.OWNER_ACCOUNT, account.name) - calInfo.put(Calendars.SYNC_EVENTS, 1) - calInfo.put(Calendars.VISIBLE, 1) - calInfo.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) - - val client: ContentProviderClient? = requireActivity().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) - return try { - client?.let { - val uri = AndroidCalendar.create(account, it, calInfo) - val calendar = LocalCalendar.findById(account, client, ContentUris.parseId(uri)) - - if (credentialsModel.requiresAuth.value == true) - CalendarCredentials(requireActivity()).put(calendar, credentialsModel.username.value, credentialsModel.password.value) - } - Toast.makeText(activity, getString(R.string.add_calendar_created), Toast.LENGTH_LONG).show() - requireActivity().invalidateOptionsMenu() + if (item.itemId == R.id.create_calendar) { + model.create(subscriptionSettingsModel, credentialsModel) true - } catch(e: Exception) { - Log.e(Constants.TAG, "Couldn't create calendar", e) - Toast.makeText(context, e.localizedMessage, Toast.LENGTH_LONG).show() + } else false - } finally { - client?.closeCompat() + + + class SubscriptionModel(application: Application) : AndroidViewModel(application) { + + private val database = AppDatabase.getInstance(getApplication()) + private val subscriptionsDao = database.subscriptionsDao() + private val credentialsDao = database.credentialsDao() + + val success = MutableLiveData(false) + val errorMessage = MutableLiveData() + + /** + * Creates a new subscription taking the data from the given models. + */ + fun create( + subscriptionSettingsModel: SubscriptionSettingsFragment.SubscriptionSettingsModel, + credentialsModel: CredentialsFragment.CredentialsModel, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + val subscription = Subscription( + displayName = subscriptionSettingsModel.title.value!!, + url = Uri.parse(subscriptionSettingsModel.url.value), + color = subscriptionSettingsModel.color.value, + ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false, + defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, + defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, + ) + + /** A list of all the ids of the inserted rows */ + val id = subscriptionsDao.add(subscription) + + // Create the credential in the IO thread + if (credentialsModel.requiresAuth.value == true) { + // If the subscription requires credentials, create them + val username = credentialsModel.username.value + val password = credentialsModel.password.value + if (username != null && password != null) { + val credential = Credential( + subscriptionId = id, + username = username, + password = password + ) + credentialsDao.create(credential) + } + } + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication()) + + success.postValue(true) + } catch (e: Exception) { + Log.e(Constants.TAG, "Couldn't create calendar", e) + errorMessage.postValue(e.localizedMessage) + } + } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt index fc31e0a3fe1563dc708ff51dbcb8a440becaabf0..034731d08c75d69ac09f9874d586ef6122e40255 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt @@ -8,27 +8,31 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.HttpUtils -import at.bitfire.icsdroid.HttpUtils.toUri import at.bitfire.icsdroid.R import at.bitfire.icsdroid.databinding.AddCalendarEnterUrlBinding -import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.URI import java.net.URISyntaxException +import okhttp3.HttpUrl.Companion.toHttpUrl class AddCalendarEnterUrlFragment: Fragment() { - private val titleColorModel by activityViewModels() + private val subscriptionSettingsModel by activityViewModels() private val credentialsModel by activityViewModels() private lateinit var binding: AddCalendarEnterUrlBinding - val pickFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + private val pickFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> if (uri != null) { // keep the picked file accessible after the first sync and reboots requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) @@ -38,21 +42,21 @@ class AddCalendarEnterUrlFragment: Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val invalidate = Observer { + val invalidate = Observer { requireActivity().invalidateOptionsMenu() } arrayOf( - titleColorModel.url, - credentialsModel.requiresAuth, - credentialsModel.username, - credentialsModel.password + subscriptionSettingsModel.url, + credentialsModel.requiresAuth, + credentialsModel.username, + credentialsModel.password ).forEach { it.observe(viewLifecycleOwner, invalidate) } binding = AddCalendarEnterUrlBinding.inflate(inflater, container, false) binding.lifecycleOwner = this - binding.model = titleColorModel + binding.model = subscriptionSettingsModel setHasOptionsMenu(true) return binding.root @@ -86,13 +90,13 @@ class AddCalendarEnterUrlFragment: Fragment() { /* dynamic changes */ - private fun validateUri(): URI? { + private fun validateUri(): Uri? { var errorMsg: String? = null - var uri: URI + var uri: Uri try { try { - uri = URI(titleColorModel.url.value ?: return null) + uri = Uri.parse(subscriptionSettingsModel.url.value ?: return null) } catch (e: URISyntaxException) { Log.d(Constants.TAG, "Invalid URL", e) errorMsg = e.localizedMessage @@ -102,16 +106,16 @@ class AddCalendarEnterUrlFragment: Fragment() { Log.i(Constants.TAG, uri.toString()) if (uri.scheme.equals("webcal", true)) { - uri = URI("http", uri.authority, uri.path, uri.query, null) - titleColorModel.url.value = uri.toString() + uri = uri.buildUpon().scheme("http").build() + subscriptionSettingsModel.url.value = uri.toString() return null } else if (uri.scheme.equals("webcals", true)) { - uri = URI("https", uri.authority, uri.path, uri.query, null) - titleColorModel.url.value = uri.toString() + uri = uri.buildUpon().scheme("https").build() + subscriptionSettingsModel.url.value = uri.toString() return null } - val supportsAuthenticate = HttpUtils.supportsAuthentication(uri.toUri()) + val supportsAuthenticate = HttpUtils.supportsAuthentication(uri) binding.credentials.visibility = if (supportsAuthenticate) View.VISIBLE else View.GONE when (uri.scheme?.lowercase()) { "content" -> { @@ -135,7 +139,7 @@ class AddCalendarEnterUrlFragment: Fragment() { credentialsModel.password.value = credentials.elementAtOrNull(1) val urlWithoutPassword = URI(uri.scheme, null, uri.host, uri.port, uri.path, uri.query, null) - titleColorModel.url.value = urlWithoutPassword.toString() + subscriptionSettingsModel.url.value = urlWithoutPassword.toString() return null } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt index 069b6c094d7be8e313be94cf0d4040f963f4ac27..e2a6b1adc12c6c8c68d837dbbc5f4d611a37357a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt @@ -23,23 +23,23 @@ import at.bitfire.icsdroid.HttpClient import at.bitfire.icsdroid.HttpUtils.toURI import at.bitfire.icsdroid.HttpUtils.toUri import at.bitfire.icsdroid.R +import java.io.InputStream +import java.io.InputStreamReader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.fortuna.ical4j.model.property.Color import okhttp3.MediaType -import java.io.InputStream -import java.io.InputStreamReader class AddCalendarValidationFragment: DialogFragment() { - private val titleColorModel by activityViewModels() + private val subscriptionSettingsModel by activityViewModels() private val credentialsModel by activityViewModels() private val validationModel by viewModels { object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - val uri = Uri.parse(titleColorModel.url.value ?: throw IllegalArgumentException("No URL given"))!! + val uri = Uri.parse(subscriptionSettingsModel.url.value ?: throw IllegalArgumentException("No URL given"))!! val authenticate = credentialsModel.requiresAuth.value ?: false return ValidationModel( requireActivity().application, @@ -60,13 +60,13 @@ class AddCalendarValidationFragment: DialogFragment() { val exception = info.exception if (exception == null) { - titleColorModel.url.value = info.uri.toString() + subscriptionSettingsModel.url.value = info.uri.toString() - if (titleColorModel.color.value == null) - titleColorModel.color.value = info.calendarColor ?: resources.getColor(R.color.lightblue) + if (subscriptionSettingsModel.color.value == null) + subscriptionSettingsModel.color.value = info.calendarColor ?: resources.getColor(R.color.lightblue) - if (titleColorModel.title.value.isNullOrBlank()) - titleColorModel.title.value = info.calendarName ?: info.uri.toString() + if (subscriptionSettingsModel.title.value.isNullOrBlank()) + subscriptionSettingsModel.title.value = info.calendarName ?: info.uri.toString() parentFragmentManager .beginTransaction() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt index 755eea74848960f5f2e6093e881b27f21a9ba52d..eda02f295ed5d606614346c1944c47b1840b4859 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt @@ -46,7 +46,7 @@ class AlertFragment: DialogFragment() { ex.printStackTrace(PrintWriter(details)) } - val share = ShareCompat.IntentBuilder.from(requireActivity()) + val share = ShareCompat.IntentBuilder(requireActivity()) .setType("text/plain") .setText(details.toString()) .createChooserIntent() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index 36d2f7c946f1b9c19e74729c288c2e22175dcc59..a82792404cd6d548dcc68e65016936420a198058 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -4,97 +4,114 @@ package at.bitfire.icsdroid.ui -import android.Manifest import android.annotation.SuppressLint import android.app.Application -import android.content.ContentUris import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.database.ContentObserver +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager -import android.provider.CalendarContract import android.provider.Settings import android.util.Log import android.view.* import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.work.WorkInfo -import at.bitfire.ical4android.CalendarStorageException import at.bitfire.icsdroid.* import at.bitfire.icsdroid.databinding.CalendarListActivityBinding import at.bitfire.icsdroid.databinding.CalendarListItemBinding -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Subscription import com.google.android.material.snackbar.Snackbar import java.text.DateFormat import java.util.* class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshListener { - private val model by viewModels() + companion object { + /** + * Set this extra to request calendar permission when the activity starts. + */ + const val EXTRA_REQUEST_CALENDAR_PERMISSION = "permission" + + const val PRIVACY_POLICY_URL = "https://icsx5.bitfire.at/privacy/" + } + + private val model by viewModels() private lateinit var binding: CalendarListActivityBinding - private var snackBar: Snackbar? = null + /** Stores the calendar permission request for asking for calendar permissions during runtime */ + private lateinit var requestCalendarPermissions: () -> Unit + + /** Stores the post notification permission request for asking for permissions during runtime */ + private lateinit var requestNotificationPermission: () -> Unit + private var snackBar: Snackbar? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTitle(R.string.title_activity_calendar_list) + // Register the calendar permission request + requestCalendarPermissions = PermissionUtils.registerCalendarPermissionRequest(this) { + SyncWorker.run(this) + } + + // Register the notifications permission request + requestNotificationPermission = PermissionUtils.registerNotificationPermissionRequest(this) + binding = DataBindingUtil.setContentView(this, R.layout.calendar_list_activity) binding.lifecycleOwner = this binding.model = model - val defaultRefreshColor = resources.getColor(R.color.lightblue) + val defaultRefreshColor = ContextCompat.getColor(this, R.color.lightblue) binding.refresh.setColorSchemeColors(defaultRefreshColor) binding.refresh.setOnRefreshListener(this) binding.refresh.setSize(SwipeRefreshLayout.LARGE) - val calendarPermissionsRequestLauncher = PermissionUtils(this).registerCalendarPermissionRequestLauncher() - model.askForPermissions.observe(this) { ask -> - if (ask) - calendarPermissionsRequestLauncher.launch(CalendarModel.PERMISSIONS) - } - + // show whether sync is running model.isRefreshing.observe(this) { isRefreshing -> binding.refresh.isRefreshing = isRefreshing } - val calendarAdapter = CalendarListAdapter(this) - calendarAdapter.clickListener = { calendar -> + // calendars + val subscriptionAdapter = SubscriptionListAdapter(this) + subscriptionAdapter.clickListener = { calendar -> val intent = Intent(this, EditCalendarActivity::class.java) - intent.data = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendar.id) + intent.putExtra(EditCalendarActivity.EXTRA_SUBSCRIPTION_ID, calendar.id) startActivity(intent) } - binding.calendarList.adapter = calendarAdapter + binding.calendarList.adapter = subscriptionAdapter binding.fab.setOnClickListener { onAddCalendar() } - model.calendars.observe(this) { calendars -> - calendarAdapter.submitList(calendars) + // If EXTRA_PERMISSION is true, request the calendar permissions + val requestPermissions = intent.getBooleanExtra(EXTRA_REQUEST_CALENDAR_PERMISSION, false) + if (requestPermissions && !PermissionUtils.haveCalendarPermissions(this)) + requestCalendarPermissions() + + model.subscriptions.observe(this) { subscriptions -> + subscriptionAdapter.submitList(subscriptions) val colors = mutableSetOf() colors += defaultRefreshColor - colors.addAll(calendars.mapNotNull { it.color }) + colors.addAll(subscriptions.mapNotNull { it.color }) binding.refresh.setColorSchemeColors(*colors.toIntArray()) } - model.reinit() // startup fragments if (savedInstanceState == null) @@ -133,11 +150,18 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis snackBar = null when { - // periodic sync not enabled - AppAccount.syncInterval(this) == AppAccount.SYNC_INTERVAL_MANUALLY -> { - snackBar = Snackbar.make(binding.coordinator, R.string.calendar_list_sync_interval_manually, Snackbar.LENGTH_INDEFINITE).also { - it.show() - } + // notification permissions are granted + !PermissionUtils.haveNotificationPermission(this) -> { + snackBar = Snackbar.make(binding.coordinator, R.string.notification_permissions_required, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.permissions_grant) { requestNotificationPermission() } + .also { it.show() } + } + + // calendar permissions are granted + !PermissionUtils.haveCalendarPermissions(this) -> { + snackBar = Snackbar.make(binding.coordinator, R.string.calendar_permissions_required, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.permissions_grant) { requestCalendarPermissions() } + .also { it.show() } } // periodic sync enabled AND Android >= 6 AND not whitelisted from battery saving AND sync interval < 1 day @@ -166,6 +190,14 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis SyncWorker.run(this, true) } + fun onRefreshRequested(item: MenuItem) { + onRefresh() + } + + fun onShowInfo(item: MenuItem) { + startActivity(Intent(this, InfoActivity::class.java)) + } + fun onSetSyncInterval(item: MenuItem) { SyncIntervalDialogFragment().show(supportFragmentManager, "sync_interval") } @@ -182,30 +214,31 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis ) } + fun onShowPrivacyPolicy(item: MenuItem) { + UriUtils.launchUri(this, Uri.parse(PRIVACY_POLICY_URL)) + } + - class CalendarListAdapter( - val context: Context - ): ListAdapter(object: DiffUtil.ItemCallback() { + class SubscriptionListAdapter( + val context: Context + ): ListAdapter(object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: LocalCalendar, newItem: LocalCalendar) = - oldItem.id == newItem.id + override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription) = + oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: LocalCalendar, newItem: LocalCalendar) = - // compare all displayed fields - oldItem.url == newItem.url && - oldItem.displayName == newItem.displayName && - oldItem.isSynced == newItem.isSynced && - oldItem.lastSync == newItem.lastSync && - oldItem.color == newItem.color && - oldItem.errorMessage == newItem.errorMessage + override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription) = + // compare all displayed fields + oldItem.url == newItem.url && + oldItem.displayName == newItem.displayName && + oldItem.lastSync == newItem.lastSync && + oldItem.color == newItem.color && + oldItem.errorMessage == newItem.errorMessage }) { class ViewHolder(val binding: CalendarListItemBinding): RecyclerView.ViewHolder(binding.root) - - var clickListener: ((LocalCalendar) -> Unit)? = null - + var clickListener: ((Subscription) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { Log.i(Constants.TAG, "Creating view holder") @@ -214,35 +247,29 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val calendar = currentList[position] + val subscription = currentList[position] holder.binding.root.setOnClickListener { clickListener?.let { listener -> - listener(calendar) + listener(subscription) } } holder.binding.apply { - url.text = calendar.url - title.text = calendar.displayName - - syncStatus.text = - if (!calendar.isSynced) - context.getString(R.string.calendar_list_sync_disabled) - else { - if (calendar.lastSync == 0L) - context.getString(R.string.calendar_list_not_synced_yet) - else - DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT) - .format(Date(calendar.lastSync)) - } - - calendar.color?.let { + url.text = subscription.url.toString() + title.text = subscription.displayName + + syncStatus.text = subscription.lastSync?.let { lastSync -> + DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT) + .format(Date(lastSync)) + } ?: context.getString(R.string.calendar_list_not_synced_yet) + + subscription.color?.let { color.setColor(it) } } - val errorMessage = calendar.errorMessage + val errorMessage = subscription.errorMessage if (errorMessage == null) holder.binding.errorMessage.visibility = View.GONE else { @@ -253,75 +280,17 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis } - - /** - * Data model for this view. Must only be created when the app has calendar permissions! - */ - class CalendarModel( - application: Application - ): AndroidViewModel(application) { - - companion object { - val PERMISSIONS = arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) - } - - private val resolver = application.contentResolver - - val askForPermissions = MutableLiveData(false) + class SubscriptionsModel(application: Application): AndroidViewModel(application) { /** whether there are running sync workers */ - val isRefreshing = Transformations.map(SyncWorker.liveStatus(application)) { workInfos -> + val isRefreshing = SyncWorker.liveStatus(application).map { workInfos -> workInfos.any { it.state == WorkInfo.State.RUNNING } } - val calendars = MutableLiveData>() - private var observer: ContentObserver? = null - - - fun reinit() { - val havePermissions = PERMISSIONS.all { ActivityCompat.checkSelfPermission(getApplication(), it) == PackageManager.PERMISSION_GRANTED } - askForPermissions.value = !havePermissions - - if (havePermissions && observer == null) - startWatchingCalendars() - } - - override fun onCleared() { - stopWatchingCalendars() - } - - - private fun startWatchingCalendars() { - val newObserver = object: ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - loadCalendars() - } - } - resolver.registerContentObserver(CalendarContract.Calendars.CONTENT_URI, false, newObserver) - observer = newObserver - - loadCalendars() - } - - private fun stopWatchingCalendars() { - observer?.let { - resolver.unregisterContentObserver(it) - observer = null - } - } - - private fun loadCalendars() { - val provider = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY) - if (provider != null) - try { - val result = LocalCalendar.findAll(AppAccount.get(getApplication()), provider) - calendars.postValue(result) - } catch(e: CalendarStorageException) { - Log.e(Constants.TAG, "Couldn't load calendar list", e) - } finally { - provider.release() - } - } + /** LiveData watching the subscriptions */ + val subscriptions = AppDatabase.getInstance(application) + .subscriptionsDao() + .getAllLive() } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt index b6eff4d6b6f89854cc7f55f4eaff9e3477140e42..ccbef15a41fe8ae6ea19ca5158c44ef3269b66ce 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt @@ -4,10 +4,12 @@ package at.bitfire.icsdroid.ui +import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AppCompatActivity -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalCalendar import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.jaredrummler.android.colorpicker.ColorPickerDialogListener @@ -17,6 +19,14 @@ class ColorPickerActivity: AppCompatActivity(), ColorPickerDialogListener { const val EXTRA_COLOR = "color" } + class Contract: ActivityResultContract() { + override fun createIntent(context: Context, input: Int?): Intent = Intent(context, ColorPickerActivity::class.java).apply { + putExtra(EXTRA_COLOR, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Int = intent?.getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) ?: LocalCalendar.DEFAULT_COLOR + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index 0dc60138b5d7ae3c127991e7e712f8b3656ff077..ebe3f929700ddd1d5b382b70ebb60089641e86af 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -4,24 +4,16 @@ package at.bitfire.icsdroid.ui -import android.Manifest -import android.annotation.SuppressLint import android.app.Application -import android.content.ContentUris -import android.content.ContentValues -import android.content.pm.PackageManager -import android.net.Uri import android.os.Bundle -import android.provider.CalendarContract -import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast +import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat import androidx.core.app.ShareCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.DialogFragment @@ -29,77 +21,96 @@ import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import at.bitfire.ical4android.CalendarStorageException -import at.bitfire.icsdroid.AppAccount -import at.bitfire.icsdroid.Constants +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import at.bitfire.icsdroid.HttpUtils import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.SyncWorker import at.bitfire.icsdroid.databinding.EditCalendarBinding -import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar -import java.io.FileNotFoundException +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Credential +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class EditCalendarActivity: AppCompatActivity() { companion object { - const val ERROR_MESSAGE = "errorMessage" - const val THROWABLE = "errorThrowable" + const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" + const val EXTRA_ERROR_MESSAGE = "errorMessage" + const val EXTRA_THROWABLE = "errorThrowable" } - private val model by viewModels() - private val titleColorModel by viewModels() + private val subscriptionSettingsModel by viewModels() private val credentialsModel by viewModels() + private val model by viewModels { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val subscriptionId = intent.getLongExtra(EXTRA_SUBSCRIPTION_ID, -1) + return SubscriptionModel(application, subscriptionId) as T + } + } + } + lateinit var binding: EditCalendarBinding override fun onCreate(inState: Bundle?) { super.onCreate(inState) + model.subscriptionWithCredential.observe(this) { data -> + if (data != null) + onSubscriptionLoaded(data) + } + val invalidate = Observer { invalidateOptionsMenu() } - - model.calendar.observe(this) { calendar -> - if (!model.loaded) { - onCalendarLoaded(calendar) - model.loaded = true - } + arrayOf( + subscriptionSettingsModel.title, + subscriptionSettingsModel.color, + subscriptionSettingsModel.ignoreAlerts, + subscriptionSettingsModel.defaultAlarmMinutes, + subscriptionSettingsModel.defaultAllDayAlarmMinutes, + credentialsModel.requiresAuth, + credentialsModel.username, + credentialsModel.password + ).forEach { element -> + element.observe(this, invalidate) } - model.active.observe(this, invalidate) - - titleColorModel.title.observe(this, invalidate) - titleColorModel.color.observe(this, invalidate) - - credentialsModel.requiresAuth.observe(this, invalidate) - credentialsModel.username.observe(this, invalidate) - credentialsModel.password.observe(this, invalidate) binding = DataBindingUtil.setContentView(this, R.layout.edit_calendar) binding.lifecycleOwner = this binding.model = model - if (inState == null) { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED && - ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) { - // permissions OK, load calendar from provider - val uri = intent.data ?: throw IllegalArgumentException("Intent data empty (must be calendar URI)") - val calendarId = ContentUris.parseId(uri) - try { - model.loadCalendar(calendarId) - } catch (e: FileNotFoundException) { - Toast.makeText(this, R.string.could_not_load_calendar, Toast.LENGTH_LONG).show() - finish() - } - } else { - Toast.makeText(this, R.string.calendar_permissions_required, Toast.LENGTH_LONG).show() + // handle status changes + model.successMessage.observe(this) { message -> + if (message != null) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() finish() } + } - intent.getStringExtra(ERROR_MESSAGE)?.let { error -> - AlertFragment.create(error, intent.getSerializableExtra(THROWABLE) as? Throwable) - .show(supportFragmentManager, null) + // show error message from calling intent, if available + if (inState == null) + intent.getStringExtra(EXTRA_ERROR_MESSAGE)?.let { error -> + AlertFragment.create(error, intent.getSerializableExtra(EXTRA_THROWABLE) as? Throwable) + .show(supportFragmentManager, null) } + + onBackPressedDispatcher.addCallback { + if (dirty()) { + // If the form is dirty, warn the user about losing changes + supportFragmentManager.beginTransaction() + .add(SaveDismissDialogFragment(), null) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + } else + // Otherwise, simply finish the activity + finish() } } @@ -119,14 +130,14 @@ class EditCalendarActivity: AppCompatActivity() { .setVisible(dirty) // if local file, hide authentication fragment - val uri = Uri.parse(model.calendar.value?.url) + val uri = model.subscriptionWithCredential.value?.subscription?.url binding.credentials.visibility = - if (HttpUtils.supportsAuthentication(uri)) + if (uri != null && HttpUtils.supportsAuthentication(uri)) View.VISIBLE else View.GONE - val titleOK = !titleColorModel.title.value.isNullOrBlank() + val titleOK = !subscriptionSettingsModel.title.value.isNullOrBlank() val authOK = credentialsModel.run { if (requiresAuth.value == true) username.value != null && password.value != null @@ -139,88 +150,64 @@ class EditCalendarActivity: AppCompatActivity() { return true } - private fun onCalendarLoaded(calendar: LocalCalendar) { - titleColorModel.url.value = calendar.url - calendar.displayName.let { - titleColorModel.originalTitle = it - titleColorModel.title.value = it + private fun onSubscriptionLoaded(subscriptionWithCredential: SubscriptionsDao.SubscriptionWithCredential) { + val subscription = subscriptionWithCredential.subscription + + subscriptionSettingsModel.url.value = subscription.url.toString() + subscription.displayName.let { + subscriptionSettingsModel.originalTitle = it + subscriptionSettingsModel.title.value = it } - calendar.color.let { - titleColorModel.originalColor = it - titleColorModel.color.value = it + subscription.color.let { + subscriptionSettingsModel.originalColor = it + subscriptionSettingsModel.color.value = it + } + subscription.ignoreEmbeddedAlerts.let { + subscriptionSettingsModel.originalIgnoreAlerts = it + subscriptionSettingsModel.ignoreAlerts.postValue(it) + } + subscription.defaultAlarmMinutes.let { + subscriptionSettingsModel.originalDefaultAlarmMinutes = it + subscriptionSettingsModel.defaultAlarmMinutes.postValue(it) + } + subscription.defaultAllDayAlarmMinutes.let { + subscriptionSettingsModel.originalDefaultAllDayAlarmMinutes = it + subscriptionSettingsModel.defaultAllDayAlarmMinutes.postValue(it) } - model.active.value = calendar.isSynced - - val (username, password) = CalendarCredentials(this).get(calendar) - val requiresAuth = username != null && password != null + val credential = subscriptionWithCredential.credential + val requiresAuth = credential != null credentialsModel.originalRequiresAuth = requiresAuth credentialsModel.requiresAuth.value = requiresAuth - credentialsModel.originalUsername = username - credentialsModel.username.value = username - credentialsModel.originalPassword = password - credentialsModel.password.value = password + + if (credential != null) { + credential.username.let { username -> + credentialsModel.originalUsername = username + credentialsModel.username.value = username + } + credential.password.let { password -> + credentialsModel.originalPassword = password + credentialsModel.password.value = password + } + } } /* user actions */ - override fun onBackPressed() { - if (dirty()) - supportFragmentManager.beginTransaction() - .add(SaveDismissDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() - else - super.onBackPressed() - } - fun onSave(item: MenuItem?) { - var success = false - model.calendar.value?.let { calendar -> - try { - val values = ContentValues(3) - values.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, titleColorModel.title.value) - values.put(CalendarContract.Calendars.CALENDAR_COLOR, titleColorModel.color.value) - values.put(CalendarContract.Calendars.SYNC_EVENTS, if (model.active.value == true) 1 else 0) - calendar.update(values) - - credentialsModel.let { model -> - val credentials = CalendarCredentials(this) - if (model.requiresAuth.value == true) - credentials.put(calendar, model.username.value, model.password.value) - else - credentials.put(calendar, null, null) - } - success = true - } catch(e: CalendarStorageException) { - Log.e(Constants.TAG, "Couldn't update calendar", e) - } - } - Toast.makeText(this, getString(if (success) R.string.edit_calendar_saved else R.string.edit_calendar_failed), Toast.LENGTH_SHORT).show() - finish() + model.updateSubscription(subscriptionSettingsModel, credentialsModel) } fun onAskDelete(item: MenuItem) { supportFragmentManager.beginTransaction() - .add(DeleteDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() + .add(DeleteDialogFragment(), null) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() } private fun onDelete() { - var success = false - model.calendar.value?.let { - try { - it.delete() - CalendarCredentials(this).put(it, null, null) - success = true - } catch(e: CalendarStorageException) { - Log.e(Constants.TAG, "Couldn't delete calendar") - } - } - Toast.makeText(this, getString(if (success) R.string.edit_calendar_deleted else R.string.edit_calendar_failed), Toast.LENGTH_SHORT).show() - finish() + model.removeSubscription() } fun onCancel(item: MenuItem?) { @@ -228,90 +215,123 @@ class EditCalendarActivity: AppCompatActivity() { } fun onShare(item: MenuItem) { - model.calendar.value?.let { - ShareCompat.IntentBuilder.from(this) - .setSubject(it.displayName) - .setText(it.url) + model.subscriptionWithCredential.value?.let { (subscription, _) -> + ShareCompat.IntentBuilder(this) + .setSubject(subscription.displayName) + .setText(subscription.url.toString()) .setType("text/plain") .setChooserTitle(R.string.edit_calendar_send_url) .startChooser() } } - private fun dirty(): Boolean { - val calendar = model.calendar.value ?: return false - return calendar.isSynced != model.active.value || - titleColorModel.dirty() || - credentialsModel.dirty() - } + private fun dirty(): Boolean = subscriptionSettingsModel.dirty() || credentialsModel.dirty() /* view model and data source */ - class CalendarModel( - application: Application + class SubscriptionModel( + application: Application, + private val subscriptionId: Long ): AndroidViewModel(application) { - var loaded = false + private val db = AppDatabase.getInstance(application) + private val credentialsDao = db.credentialsDao() + private val subscriptionsDao = db.subscriptionsDao() + + val successMessage = MutableLiveData() - var calendar = MutableLiveData() - val active = MutableLiveData() + val subscriptionWithCredential = db.subscriptionsDao().getWithCredentialsByIdLive(subscriptionId) /** - * Loads the requested calendar from the Calendar Provider. - * - * @param id calendar ID - * - * @throws FileNotFoundException when the calendar doesn't exist (anymore) + * Updates the loaded subscription from the data provided by the view models. */ - fun loadCalendar(id: Long) { - @SuppressLint("Recycle") - val provider = getApplication().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) ?: return - try { - calendar.value = LocalCalendar.findById(AppAccount.get(getApplication()), provider, id) - } finally { - provider.release() + fun updateSubscription( + subscriptionSettingsModel: SubscriptionSettingsFragment.SubscriptionSettingsModel, + credentialsModel: CredentialsFragment.CredentialsModel + ) { + viewModelScope.launch(Dispatchers.IO) { + subscriptionWithCredential.value?.let { subscriptionWithCredentials -> + val subscription = subscriptionWithCredentials.subscription + + val newSubscription = subscription.copy( + displayName = subscriptionSettingsModel.title.value ?: subscription.displayName, + color = subscriptionSettingsModel.color.value, + defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, + defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, + ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false + ) + subscriptionsDao.update(newSubscription) + + if (credentialsModel.requiresAuth.value == true) { + val username = credentialsModel.username.value + val password = credentialsModel.password.value + if (username != null && password != null) + credentialsDao.upsert(Credential(subscriptionId, username, password)) + } else + credentialsDao.removeBySubscriptionId(subscriptionId) + + // notify UI about success + successMessage.postValue(getApplication().getString(R.string.edit_calendar_saved)) + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication(), forceResync = true) + } } } - } + /** + * Removes the loaded subscription. + */ + fun removeSubscription() { + viewModelScope.launch(Dispatchers.IO) { + subscriptionWithCredential.value?.let { subscriptionWithCredentials -> + subscriptionsDao.delete(subscriptionWithCredentials.subscription) + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication()) + // notify UI about success + successMessage.postValue(getApplication().getString(R.string.edit_calendar_deleted)) + } + } + } + + } - /* "Save or dismiss" dialog */ - class SaveDismissDialogFragment: DialogFragment() { + /** "Really delete?" dialog */ + class DeleteDialogFragment: DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?) = - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.edit_calendar_unsaved_changes) - .setPositiveButton(R.string.edit_calendar_save) { dialog, _ -> - dialog.dismiss() - (activity as? EditCalendarActivity)?.onSave(null) - } - .setNegativeButton(R.string.edit_calendar_dismiss) { dialog, _ -> - dialog.dismiss() - (activity as? EditCalendarActivity)?.onCancel(null) - } - .create() + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.edit_calendar_really_delete) + .setPositiveButton(R.string.edit_calendar_delete) { dialog, _ -> + dialog.dismiss() + (activity as EditCalendarActivity?)?.onDelete() + } + .setNegativeButton(R.string.edit_calendar_cancel) { dialog, _ -> + dialog.dismiss() + } + .create() } - - /* "Really delete?" dialog */ - - class DeleteDialogFragment: DialogFragment() { + /** "Save or dismiss" dialog */ + class SaveDismissDialogFragment: DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?) = - AlertDialog.Builder(requireActivity()) - .setMessage(R.string.edit_calendar_really_delete) - .setPositiveButton(R.string.edit_calendar_delete) { dialog, _ -> - dialog.dismiss() - (activity as EditCalendarActivity?)?.onDelete() - } - .setNegativeButton(R.string.edit_calendar_cancel) { dialog, _ -> - dialog.dismiss() - } - .create() + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.edit_calendar_unsaved_changes) + .setPositiveButton(R.string.edit_calendar_save) { dialog, _ -> + dialog.dismiss() + (activity as? EditCalendarActivity)?.onSave(null) + } + .setNegativeButton(R.string.edit_calendar_dismiss) { dialog, _ -> + dialog.dismiss() + (activity as? EditCalendarActivity)?.onCancel(null) + } + .create() } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..7e78492a02d9430e280ea12a05df205b9cd10f8e --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt @@ -0,0 +1,168 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui + +import android.os.Bundle +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.drawable.toBitmap +import androidx.core.text.HtmlCompat +import at.bitfire.icsdroid.BuildConfig +import at.bitfire.icsdroid.R +import com.google.accompanist.themeadapter.material.MdcTheme + +class InfoActivity: ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MainLayout() + } + } + + @Composable + @Preview + fun MainLayout() { + MdcTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton({ onNavigateUp() }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = null + ) + } + }, + title = { + Text( + stringResource(R.string.app_name) + ) + }, + ) + } + ) { contentPadding -> + Column(Modifier.padding(contentPadding)) { + Header() + License() + } + } + } + } + + @Composable + fun Header() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val context = LocalContext.current + + Image( + bitmap = context.applicationInfo + .loadIcon(context.packageManager) + .toBitmap() + .asImageBitmap(), + contentDescription = null, + modifier = Modifier + .padding(vertical = 12.dp) + .size(72.dp) + ) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onBackground + ) + Text( + text = stringResource(R.string.app_fork_info), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.alpha(ContentAlpha.medium) + ) + Text( + text = stringResource( + R.string.app_info_version, + BuildConfig.VERSION_NAME, + BuildConfig.FLAVOR + ), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.alpha(ContentAlpha.medium) + ) + } + } + + @Composable + fun License() { + val showLicenseDialog = rememberSaveable { mutableStateOf(false) } + if (showLicenseDialog.value) + TextDialog(R.string.app_info_gplv3_note, showLicenseDialog) + + Row { + OutlinedButton( + onClick = { showLicenseDialog.value = true }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) { + Text(stringResource(R.string.app_info_gplv3)) + } + } + } + + @Composable + fun TextDialog(@StringRes text: Int, state: MutableState, buttons: @Composable () -> Unit = {}) { + AlertDialog( + text = { + AndroidView({ context -> + TextView(context).also { + it.text = HtmlCompat.fromHtml( + getString(text).replace("\n", "
"), + HtmlCompat.FROM_HTML_MODE_COMPACT) + } + }, modifier = Modifier.verticalScroll(rememberScrollState())) + }, + buttons = buttons, + onDismissRequest = { state.value = false } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt index bf925c10c4409eb2c7fb653bd12d3a9a86ffc76a..510f7261b7f3b60923270c74e620ccad907da7fd 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt @@ -8,7 +8,9 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.os.Build +import androidx.core.app.NotificationCompat import at.bitfire.icsdroid.R object NotificationUtils { @@ -36,4 +38,25 @@ object NotificationUtils { return nm } + /** + * Shows a notification informing the user that the calendar permission is required but has not + * been granted. + */ + fun showCalendarPermissionNotification(context: Context) { + val nm = createChannels(context) + val askPermissionsIntent = Intent(context, CalendarListActivity::class.java).apply { + putExtra(CalendarListActivity.EXTRA_REQUEST_CALENDAR_PERMISSION, true) + } + val notification = NotificationCompat.Builder(context, CHANNEL_SYNC) + .setSmallIcon(R.drawable.ic_sync_problem_white) + .setContentTitle(context.getString(R.string.sync_permission_required)) + .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + flagImmutableCompat)) + .setAutoCancel(true) + .setLocalOnly(true) + .build() + nm.notify(NOTIFY_PERMISSION, notification) + } + } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..ee365aa19e5e50a55ae1b82407acc49c8d5bae5d --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt @@ -0,0 +1,161 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui + +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.CompoundButton.OnCheckedChangeListener +import android.widget.EditText +import android.widget.TextView +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.databinding.SubscriptionSettingsBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial +import org.joda.time.Minutes +import org.joda.time.format.PeriodFormat + +class SubscriptionSettingsFragment : Fragment() { + + private val model by activityViewModels() + + private lateinit var binding: SubscriptionSettingsBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { + binding = SubscriptionSettingsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = this + binding.model = model + + model.defaultAlarmMinutes.observe( + viewLifecycleOwner, + defaultAlarmObserver( + binding.defaultAlarmSwitch, + binding.defaultAlarmText, + model.defaultAlarmMinutes + ) + ) + model.defaultAllDayAlarmMinutes.observe( + viewLifecycleOwner, + defaultAlarmObserver( + binding.defaultAlarmAllDaySwitch, + binding.defaultAlarmAllDayText, + model.defaultAllDayAlarmMinutes + ) + ) + + val colorPickerContract = registerForActivityResult(ColorPickerActivity.Contract()) { color -> + model.color.value = color + } + binding.color.setOnClickListener { + colorPickerContract.launch(model.color.value) + } + + return binding.root + } + + /** + * Provides an observer for the default alarm fields. + * @param switch The switch view that updates the currently stored minutes. + * @param textView The viewer for the current value of the stored minutes. + * @param selectedMinutes The LiveData instance that holds the currently selected amount of minutes. + */ + private fun defaultAlarmObserver( + switch: SwitchMaterial, + textView: TextView, + selectedMinutes: MutableLiveData + ) = Observer { min: Long? -> + switch.isChecked = min != null + // We add the listener once the switch has an initial value + switch.setOnCheckedChangeListener(getOnCheckedChangeListener(switch, selectedMinutes)) + + if (min == null) + textView.text = getString(R.string.add_calendar_alarms_default_none) + else { + val alarmPeriodText = PeriodFormat.wordBased().print(Minutes.minutes(min.toInt())) + textView.text = getString(R.string.add_calendar_alarms_default_description, alarmPeriodText) + } + } + + /** + * Provides an [OnCheckedChangeListener] for watching the checked changes of a switch that + * provides the alarm time in minutes for a given parameter. Also holds the alert dialog that + * asks the user the amount of time to set. + * @param switch The switch that is going to update the selection of minutes. + * @param observable The state holder of the amount of minutes selected. + */ + private fun getOnCheckedChangeListener( + switch: SwitchMaterial, + observable: MutableLiveData + ) = OnCheckedChangeListener { _, checked -> + if (!checked) { + observable.value = null + return@OnCheckedChangeListener + } + + val editText = EditText(requireContext()).apply { + setHint(R.string.default_alarm_dialog_hint) + isSingleLine = true + maxLines = 1 + imeOptions = EditorInfo.IME_ACTION_DONE + inputType = InputType.TYPE_CLASS_NUMBER + + addTextChangedListener { txt -> + val text = txt?.toString() + val num = text?.toLongOrNull() + error = if (text == null || text.isBlank() || num == null) + getString(R.string.default_alarm_dialog_error) + else + null + } + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.default_alarm_dialog_title) + .setMessage(R.string.default_alarm_dialog_message) + .setView(editText) + .setPositiveButton(R.string.default_alarm_dialog_set) { dialog, _ -> + if (editText.error == null) { + observable.value = editText.text?.toString()?.toLongOrNull() + dialog.dismiss() + } + } + .setOnCancelListener { + switch.isChecked = false + } + .create() + .show() + } + + class SubscriptionSettingsModel : ViewModel() { + var url = MutableLiveData() + + var originalTitle: String? = null + val title = MutableLiveData() + + var originalColor: Int? = null + val color = MutableLiveData() + + var originalIgnoreAlerts: Boolean? = null + val ignoreAlerts = MutableLiveData() + + var originalDefaultAlarmMinutes: Long? = null + val defaultAlarmMinutes = MutableLiveData() + + var originalDefaultAllDayAlarmMinutes: Long? = null + val defaultAllDayAlarmMinutes = MutableLiveData() + + fun dirty(): Boolean = originalTitle != title.value || originalColor != color.value || originalIgnoreAlerts != ignoreAlerts.value || + originalDefaultAlarmMinutes != defaultAlarmMinutes.value || originalDefaultAllDayAlarmMinutes != defaultAllDayAlarmMinutes.value + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt deleted file mode 100644 index 012cff6928d9f345977f9706a021ccf935f17f9a..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt +++ /dev/null @@ -1,59 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import at.bitfire.icsdroid.databinding.TitleColorBinding -import at.bitfire.icsdroid.db.LocalCalendar - -class TitleColorFragment: Fragment() { - - private val model by activityViewModels() - - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val binding = TitleColorBinding.inflate(inflater, container, false) - binding.lifecycleOwner = this - binding.model = model - - binding.color.setOnClickListener { - val intent = Intent(requireActivity(), ColorPickerActivity::class.java) - model.color.value?.let { - intent.putExtra(ColorPickerActivity.EXTRA_COLOR, it) - } - startActivityForResult(intent, 0) - } - return binding.root - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) { - result?.let { - model.color.value = it.getIntExtra(ColorPickerActivity.EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) - } - } - - class TitleColorModel: ViewModel() { - var url = MutableLiveData() - - var originalTitle: String? = null - val title = MutableLiveData() - - var originalColor: Int? = null - val color = MutableLiveData() - - fun dirty() = - originalTitle != title.value || - originalColor != color.value - } - -} diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000000000000000000000000000000000..2808247529b0fac69dff5b08e452ae9a58f8793f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/add_calendar_details.xml b/app/src/main/res/layout/add_calendar_details.xml index a0b271f7ea0ca0db2590bca2b58282f78ba3b2bb..9bb64c7ad77463e27fad217d7a2ebca3a6cea43d 100644 --- a/app/src/main/res/layout/add_calendar_details.xml +++ b/app/src/main/res/layout/add_calendar_details.xml @@ -5,7 +5,7 @@ diff --git a/app/src/main/res/layout/add_calendar_enter_url.xml b/app/src/main/res/layout/add_calendar_enter_url.xml index 6062aef63aeff97005177c4a04bd50afbfa052b9..61f0e726ff432383a5c859b432e134fd1bba3a7c 100644 --- a/app/src/main/res/layout/add_calendar_enter_url.xml +++ b/app/src/main/res/layout/add_calendar_enter_url.xml @@ -1,7 +1,7 @@ - + - + @@ -25,7 +25,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - android:visibility="@{model.calendars.empty ? View.GONE : View.VISIBLE }" /> + android:visibility="@{model.subscriptions.empty ? View.GONE : View.VISIBLE }" /> @@ -37,7 +37,7 @@ style="@style/TextAppearance.MaterialComponents.Body1" android:layout_margin="16dp" android:text="@string/calendar_list_empty_info" - android:visibility="@{model.calendars.empty ? View.VISIBLE : View.GONE }" /> + android:visibility="@{model.subscriptions.empty ? View.VISIBLE : View.GONE }" /> - + - - + android:name="at.bitfire.icsdroid.ui.SubscriptionSettingsFragment"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/title_color.xml b/app/src/main/res/layout/title_color.xml deleted file mode 100644 index 0042ee67c85ef36242bb304e0fe341b5f0ef1d8a..0000000000000000000000000000000000000000 --- a/app/src/main/res/layout/title_color.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/activity_calendar_list.xml b/app/src/main/res/menu/activity_calendar_list.xml index c389a73319044fe5e1f8beac5d9aec0a6a40a5bb..6d60e29f58b9d7ca21342b574a3a20dd4597c920 100644 --- a/app/src/main/res/menu/activity_calendar_list.xml +++ b/app/src/main/res/menu/activity_calendar_list.xml @@ -7,10 +7,18 @@ android:onClick="onSetSyncInterval" app:showAsAction="never"/> + + + + diff --git a/app/src/main/res/menu/app_info_activity.xml b/app/src/main/res/menu/app_info_activity.xml deleted file mode 100644 index 49301e52323020724a7b7d79a884740c7cef599c..0000000000000000000000000000000000000000 --- a/app/src/main/res/menu/app_info_activity.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..1921500d8137d7827ac4896fa6e7e548822086e0 --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,92 @@ + + + Subscripcions a calendaris + + Següent + Cal permís d\'accés al calendari + Cal el permís de notificacions + No s\'ha pogut carregar el calendari + Problemes de sincronització + Permisos necessaris + Concedir + + Les meues subscripcions + Per a subscriure\'t a un fil Webcal, prem el botó +, o obre una URL Webcal. + Sobre ICSx⁵ + encara no sincronitzat + Període de sincronització + Sincronitza ara + Bateria: afegeix ICSx⁵ a la llista blanca per a intervals curts + Configuració + + Força el tema obscur + + Subscriure\'s a un calendari + Cal una URI vàlida + Altres persones poden interceptar les teues credencials, utilitza HTTPS per a una autentificació segura. + Subscriure\'s ara + Subscrit correctament + Contrassenya + Cal autenticar-se + Títol i Color + Nom del calendari + Introdueix una direcció Webcal: + També pots seleccionar un fitxer de l\'emmagatzematge local. + Tria un fitxer + Nom d\'usuari + Validant el recurs de calendari… + Alarmes + Ignora les alertes incloses al calendari + Si està habilitat, totes les alarmes que vinguen del servidor seran eliminades. + Afegeix una alarma per defecte per als esdeveniments + Afegeix una alarma per defecte per als esdeveniments de tot el dia + Alarma %s abans de començar + Cap alarma per defecte + Afegir alarma per defecte + Açò afegirà una alarma per a tots els esdeveniments + Minuts abans de l\'esdeveniment + Establir + Introdueix un nombre vàlid + + Compartir detalls + + Edita la subscripció + Cancel·lar + Desubscriure\'s + S\'ha desubscrit correctament + Amaga + Operació fallida + Segur que et vols desubscriure d\'aquest calendari? + Desa + Canvis desats + Envia la URL + Sincronitza aquest calendari + Hi han canvis sense desar. + + Error de sincronització + Desa + Estableix el període de sincronització: + + Només manualment + Cada 15 minuts + Cada hora + Cada 2 hores + Cada 4 hores + Una vegada al dia + Setmanalment + + Permisos necessaris + Cal tindre permís per a sincronitzar el calendari + No s\'ha pogut obrir el fitxer de l\'emmagatzematge + + Informació de l\'app + Versió %1$s-%2$s + Donar + Subscriu-te a fils Webcal + Notícies i Novetats + Lloc web + Informació de codi obert + Ens alegra que faces servir ICSx⁵, que és un programari de codi obert (GPLv3). Ja que ha sigut i segueix sent un treball dur mantindre i seguir desenvolupant ICSx⁵, per favor, considera una donació. + Mostra la pàgina de donació + Pot ser més tard + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8eb3216c63f7903d62ccc01e40537d7ed610f091..25c3e2944b1fe6340729acbf455235e153fe87cf 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2,25 +2,43 @@ Další + Kalendář nelze načíst + Povolit O aplikaci Web Calendar Manager + zatím nesynchromizováno Nastavit interval synchronizace - Synchronizace vypnuta - Automatická synchronizace vypnuta + Synchronizovat nyní Nastavení Heslo Vyžaduje autentizaci + Jméno kalendáře + Nebo zvolte soubor ze zařízení. + Zvolit soubor Uživatelské jméno + Sdílet detaily Zrušit Operace selhala Uložit + Změny uloženy + Chyba synchronizace Uložit + + Pouze ručně + Každých 15 minut + Každou hodinu + Každé 2 hodiny + Každé 4 hodiny + Jednou denně + Jednou týdně + Informace o aplikaci + Jsme rádi, že používáte ICSx⁵, což je open-source software (GPLv3). Protože vývoj ICSx⁵ vyžadoval a stále vyžaduje hodně práce, zvažte prosím přispění. Možná později diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..0205d2dbf668a21ba3fe132ece714f6962a13f11 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,74 @@ + + + Kalender abonnementer + + Næste + Kalenderrettigher er nødvendige + Synkroniseringsfejl + + Mine abonnementer + For at abonnere en Webcal feed, brug + knappen eller åben en Webcal URL. + Om ICSx⁵ + ikke synkroniseret endnu + Sæt interval til synkronisering + Batteri: Whitelist ICSx⁵ for korte sync intervaller + Indstillinger + + Anvend mørk tema + + Abonner kalender + Korrekt URI nødvendig + Tredjeparts personer kan nemt læse dine adgangsindstillinger. Brug HTTPS for sikker autentificering. + Abonner nu + Succesfuld abonneret + Kodeord + Kræver autentificering + Titel & Farve + Kalendernavn + Angiv Webcal adresse + Eller, vælg fil fra lokal lagerplads. + Vælg fil + Brugernavn + Validerer kalenderkilden ... + + Del detaljer + + Rediger abonnement + Annuller + Afbestil + Afmelding succesfuld + Ryd + Operationen fejlede + Vil du virkelig stoppe abonnementet af denne kalender? + Gem + Ændringer blev gemt + Send URL + Synkroniser denne kalender + Der er ikke gemte ændringer + + Fejl ved synkronisering + Gem + Sæt interval for synkronisering + + Kun manuelt + Hvert 15. minut + Hver time + Hver 2. time + Hver 4 timer + Engang om dagen + Engang om ugen + + Kræver tilladelse + Har brug for tilladelse til at synkronisere kalender + + App information + Version %1$s-%2$s + Donér + Abonner Webcal feeds + Nyheder & opdateringer + Hjemmeside + Open-Source information + Vi er glæde, at du bruger ICSx⁵, som er open-source software (GPLv3). Fordi det var og stadig er meget arbejde at udvikle og vedligeholde ICSx⁵, venligst overvej en donation. + Vis donationsside + Senere måske + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2b94bb112ffaef1ba5cbbf9c5d984e5eb3bef446..35fa7fc7451c12d4b6b68cba07b24a77f165bb5b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,18 +4,14 @@ Weiter Kalender-Berechtigungen benötigt - Sync-Probleme Kalender konnte nicht geladen werden + Sync-Probleme Abonnierte Kalender Noch kein Webcal-Abo! Mit + oder durch Öffnen eines Webcal-fähigen URLs hinzufügen. Über Web Calendar Manager noch nicht übertragen Aktualisierungsintervall festlegen - Automatische Aktualisierung deaktiviert - Automatische Synchronisierung deaktiviert - Automatische Synchronisierung systemweit deaktiviert - Aktivieren Für kurze Intervalle: Akku-Optimierung für Web Calendar Manager deaktivieren Einstellungen @@ -32,7 +28,6 @@ Web Calendar Manager deaktivieren Name & Farbe Kalendername Webcal Adresse eingeben - https://beispieldomain.de/webcal.ics Alternativ eine Datei aus dem lokalen Dateisystem auswählen Datei auswählen Benutzername @@ -71,13 +66,6 @@ Web Calendar Manager deaktivieren Über Web Calendar Manager Version %1$s-%2$s - Spenden Webcal-Feeds abonnieren Aktuelles diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5cae60752eec2c246c311be3ec886d215a751ee7..b817cde55dc1ead922ca6ad7716c49430cc770c5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -62,12 +62,6 @@ Info de la app %2$sVersión %1$s- - <![CDATA[ -Este programa es software libre: puedes redistribuirlo o modificarlo bajo los términos de la Licencia Pública General de GNU publicada por la Free Software Foundation, en la versión 3 de la Licencia o (a tu elección) cualquier versión posterior. - -Est programa se distribuye con la esperanza de que sea útil, pero SIN GARANTÍA; sin ni siquiera la garantía implícita de COMERCIABILIDAD o ADECUACIÓN A UN PROPÓSITO PARTICULAR. Véase la Licencia Pública General de GN para más detalles. - -eberías haber recibido una copia de la Licencia Pública General de GNU junto con este programa. Si no es así, ver <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>. Donar Suscribirse a fuentes Webcal Noticias y actualizaciones diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4b016aec9c881187326e653568928203b9598dd6..35a6746bfe881b3492ef77d40d4c63d34c0d929c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -3,8 +3,12 @@ Abonnements à des calendriers Suivant - Permissions du calendrier requises + Autorisations d\'agenda requises + Autorisations de notification requises + Impossible de charger le calendrier Problèmes de synchro. + Autorisations requises + Accorder Mes abonnements Pour s\'abonner à un flux Webcal, utilisez le bouton + ou ouvrez une URL Webcal @@ -16,6 +20,7 @@ La synchro. globale automatique est désactivée Activer Batterie : Ajoutez Web Calendar Manager aux exceptions pour des intervalles de synchronisation courts + Synchroniser maintenant Paramètres Forcer le thème sombre @@ -30,10 +35,22 @@ Intitulé & couleur Nom du calendrier Entrez une adresse Webcal : - Ou sinon, sélectionnez un fichier du stockage local. + Ou sélectionnez un fichier du stockage local. Choisir un fichier Nom d\'utilisateur Validation du calendrier… + Alarmes + Ignorer les alertes inclues dans le calendrier + Si activé, toutes les alarmes venant du serveur seront ignorées. + Ajouter une alarme par défaut à tous les événements + Ajouter une alarme par défaut pour les événements durant toute la journée + Alarme %s avant le début + Pas d\'alarme par défaut + Ajouter une alarme par défaut + Ceci ajoutera une alarme à tous les événements + Minutes avant l\'événement + Définir + Ajouter un nombre valide Partager les détails @@ -64,6 +81,7 @@ Autorisation requise Autorisation nécessaire pour la synchro. de votre calendrier + Impossible d’ouvrir le fichier à partir de l’espace de stockage Infos sur l\'application Version %1$s-%2$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..926cd85c91b7be54f0567edaebc45d4e6b831c81 --- /dev/null +++ b/app/src/main/res/values-gl/strings.xml @@ -0,0 +1,71 @@ + + + Subscricións a calendarios + + Seguinte + Precísase permiso para o calendario + Problemas de Sincr + + Subscricións + Para subscribirte a unha fonte Webcal, utiliza o botón + ou abre un URL Webcal. + Acerca de ICSx⁵ + aínda non sincronizaches + Establece intervalo de sincr. + Batería: Permitelle a ICSx⁵ intervalos curtos de sincr + Axustes + + Forzar decorado escuro + + Subscribir a calendario + Require URI válido + Terceiras partes poden interceptar facilmente as túas credenciais. Usa HTTPS para autenticación segura. + Subscríbete agora + Subscrición correcta + Contrasinal + Require autenticación + Título e Cor + Nome do calendario + Nome de usuaria + Validando fonte do calendario... + + Comparte detalles + + Editar subscrición + Cancelar + Retirar subscrición + Retirada correctamente + Desbotar + Fallou a operación + Tes a certeza de que queres retirar a subscrición a este calendario? + Gardar + Cambios gardados + Enviar URL + Sincronizar este calendario + Hai cambios sen gardar. + + Erro de sincronización + Gardar + Establece intervalo de sincr.: + + Só manual + Cada 15 minutos + Cada hora + Cada 2 horas + Cada 4 horas + Unha vez ao día + Unha vez á semana + + Permiso requerido + Precísase permiso para sincronizar o calendario + + Info da app + Versión %1$s - %2$s + Doar + Subscríbete a fontes Webcal + Novas e actualizacións + Sitio web + Información Código-Aberto + Alégranos que utilices ICSx⁵, que é software de código aberto (GPLv3). Manter e desenvolver ICSx⁵ require moito traballo, e como queda moito por facer, considera facer unha doazón. + Ir á páxina de doazóns + Máis tarde + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a123c543f840aca36d448990316208dc29304aa0..9eeda975f6c03eebe55698810bb31e941d85dab0 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,8 +4,8 @@ Következő Naptár engedélyre van szükség - Szinkronizációs problémák A naptár betöltése nem sikerült + Szinkronizációs problémák Feliratkozások Webcal folyamra a + gomb használatával vagy a Webcal URL megnyitásával iratkozhat fel. @@ -31,7 +31,6 @@ Cím és szín Naptár neve Addja meg a Webcal címet: - https://example.com/webcal.ics Alternatívaként adjon meg egy az eszközön tárolt fájlt. Fájl kiválasztása Felhasználónév @@ -70,20 +69,6 @@ Alkalmazásinformáció Verziószám: %1$s-%2$s - https://www.gnu.org/licenses/ címen. - ]]> Támogatás Feliratkozás egy Webcal folyamra Hírek és frissítések diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..601fa3ae149dc5c2bcf956ed46d83de34de6afda --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,103 @@ + + + カレンダーの購読 + + 次へ + カレンダーへのアクセスを許可してください + 通知を許可してください + カレンダーを読み込めませんでした + ブラウザーがインストールされていません + 同期に問題が発生しました + 権限を許可してください + 許可 + ウェブブラウザーをインストールしてください + + 購読中のカレンダー + + ボタンから登録するか Webcal URL を開いて Webcal フィードを購読できます + ICSx⁵ について + まだ同期されていません + 同期間隔を設定 + 今すぐ同期 + バッテリー: 一定間隔で同期を実行するため ICSx⁵ をホワイトリストに追加してください + 設定 + + ダークテーマを使用 + プライバシーポリシー + + カレンダーを購読 + 有効な URI が必要です + 第三者が簡単にあなたの認証情報にアクセスできます。HTTPS を使用して認証情報を保護してください + 購読する + 購読が完了しました + パスワード + 認証情報が必要 + 件名と色 + カレンダーの名前 + Webcal アドレスを入力: + ローカルストレージからファイルを選択することもできます + ファイルを選択 + ユーザー名 + カレンダー情報を確認しています... + アラーム + カレンダーに埋め込まれた通知を無視 + 有効にすると、そのサーバーから送信されるすべてのアラームが無視されます + すべての予定にデフォルトのアラームを追加 + 終日の予定にデフォルトのアラームを追加 + 開始 %s前にアラーム + デフォルトのアラームはありません + デフォルトのアラームを追加 + 設定すると、すべての予定にアラームを追加します + 予定の...分前 + 設定 + 有効な数字を入力してください + + 詳細情報を共有 + + 購読を編集 + キャンセル + 購読解除 + 購読を解除しました + 無視 + 操作に失敗しました + 本当にカレンダーの購読を解除しますか? + 保存 + 変更が保存されました + URL を送信 + このカレンダーを同期 + 保存されていない変更があります + + 同期エラー + 保存 + 同期間隔を設定: + + 手動のみ + 15 分ごと + 1 時間ごと + 2 時間ごと + 4 時間ごと + 毎日 1 回 + 毎週 1 回 + + 権限が必要です + カレンダーを同期するには権限を許可してください + ストレージのファイルを開けません + + アプリ情報 + バージョン %1$s-%2$s + https://www.gnu.org/licenses/ をご覧ください。 + \"]]> + 寄付 + Webcal フィードを購読 + ニュース & アップデート + ウェブサイト + オープンソース情報 + オープンソースソフトウェア (GPLv3) として ICSx⁵ をお届けできることをとても嬉しく思っています。ICSx⁵ の開発と維持を支える寄付をご検討ください。 + 寄付ページを表示 + あとで + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 5dfe6c8c13e1d479e8bb8db3d8d6007583603179..0ead725754818ecfe86ac53867ac9d6ea8229f14 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -4,18 +4,15 @@ Volgende Kalenderrechten nodig - Sync-problemen Kan kalender niet laden + Sync-problemen + Rechten vereist Mijn abonnementen De + knop gebruiken of een Webcal-URL openen om te abonneren op een Webcal-feed. Over Web Calendar Manager nog niet gesynchroniseerd Sync-interval instellen - Sync uitgeschakeld - Automatische sync uitgeschakeld - Systeem-brede automatische sync is uitgezet - Activeren Onbeperkt batterijgebruik voor korte sync-intervallen Instellingen @@ -31,11 +28,19 @@ Titel & Kleur Kalendernaam Webcal-adres invoeren: - https://example.com/webcal.ics Of een bestand uit de lokale opslag kiezen. Bestand kiezen Gebruikersnaam Kalenderbron valideren... + Herinneringen + Negeer ingebouwde kalender-herinneringen + Bij inschakelen worden alle inkomende herinneringen van de server genegeerd. + Voeg een standaard herinnering toe voor elke gebeurtenis + Standaard herinnering toevoegen + Dit voegt een herinnering voor alle gebeurtenissen toe + Minuten voor gebeurtenis + Instellen + Voer een geldig getal in Details delen @@ -70,20 +75,6 @@ App-info Versie %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Doneren Op Webcal feeds abonneren Nieuws & updates diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..c932e61113744535487c938d52f6cdb937c43be1 --- /dev/null +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -0,0 +1,94 @@ + + + Subskrypcje kalendarzy + + Dalej + Wymagane uprawnienia kalendarza + Wymagane uprawnienia do powiadomień + Nie można załadować kalendarza + Problemy z synchronizacją + Wymagane uprawnienia + Udziel + Zainstaluj przeglądarkę internetową + + Moje subskrypcje + Aby zasubskrybować kanał Webcal, użyj przycisku + lub otwórz adres URL Webcal. + O aplikacji ICSx⁵ + jeszcze nie zsynchronizowane + Ustaw interwał synchronizacji + Zsynchronizuj teraz + Bateria: Biała lista ICSx⁵ dla krótkich interwałów synchronizacji + Ustawienia + + Wymuś ciemny motyw + Polityka prywatności + + Subskrybuj kalendarz + Wymagany prawidłowy identyfikator URI + Osoby trzecie mogą łatwo przechwycić Twoje dane uwierzytelniające. Użyj protokołu HTTPS do bezpiecznego uwierzytelniania. + Subskrybuj teraz + Zasubskrybowano pomyślnie + Hasło + Wymaga uwierzytelnienia + Tytuł i kolor + Nazwa kalendarza + Wpisz adres Webcal: + Alternatywnie wybierz plik z pamięci lokalnej. + Wybierz plik + Nazwa użytkownika + Sprawdzanie poprawności zasobu kalendarza... + Alarmy + Ignoruj ​​alerty osadzone w kalendarzu + Jeśli ta opcja jest włączona, wszystkie alarmy przychodzące z serwera zostaną odrzucone. + Dodaj domyślny alarm dla wszystkich wydarzeń + Dodaj domyślny alarm dla wydarzeń całodniowych + Alarm %s przed uruchomieniem + Brak domyślnego alarmu + Dodaj domyślny alarm + Spowoduje to dodanie alarmu dla wszystkich wydarzeń + Minuty przed wydarzeniem + Komplet + Wprowadź prawidłowy numer + + Udostępnij szczegóły + + Edytuj subskrypcję + Anuluj + Anuluj subskrypcję + Subskrypcja została pomyślnie anulowana + Odrzuć + Operacja nie powiodła się + Czy na pewno chcesz anulować subskrypcję tego kalendarza? + Zapisz + Zmiany zapisane + Wyślij adres URL + Zsynchronizuj ten kalendarz + Istnieją niezapisane zmiany. + + Błąd synchronizacji + Zapisz + Ustaw interwał synchronizacji: + + Tylko ręcznie + Co 15 minut + Co godzinę + Co 2 godziny + Co 4 godziny + Raz dziennie + Raz w tygodniu + + Wymagane uprawnienia + Potrzebne uprawnienia do synchronizacji kalendarza + Nie można otworzyć pliku z pamięci masowej + + Informacje o aplikacji + Wersja %1$s-%2$s + Darowizna + Subskrybuj kanały Webcal + Nowości i aktualizacje + Strona internetowa + Informacje Open Source + Cieszymy się, że korzystasz z ICSx⁵, oprogramowania open source (GPLv3). Ponieważ rozwój i utrzymanie ICSx⁵ było i nadal wymaga wiele pracy, rozważ darowiznę. + Pokaż stronę darowizny + Może później + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 05a4f06536fae8cb8dfe09c37da5f6db87b9809b..33b74989f8b70d04016dbeadcd447c03b59b17ef 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -62,20 +62,6 @@ Informações do aplicativo Versão %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Doações Inscrição em feeds Webcal Novidades e atualizações diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..44841f32ae04d46a9c3742ba2a8c534521a7dac7 --- /dev/null +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,69 @@ + + + Calendários subscritos + + Seguinte + Necessárias permissões de calendário + Problemas de sincronização + + Os meus calendários + Para subscrever uma feed Webcal, utilize o botão + ou abra um endereço/URL Webcal. + Acerca ICSx⁵ + ainda não sincronizado + Definir intervalo de sincronização + Bateria: Autorizar ICSx⁵ para permitir intervalos de sincronização curtos + Definições + + + Subscrever calendário + Endereço/URI válido necessário + As suas credenciais podem ser facilmente intercetadas por terceiros. Utilize HTTPS para uma autenticação segura. + Subscrever agora + Subscrito com sucesso + Palavra-passe + Requer autenticação + Título e cor + Nome de calendário + Nome de utilizador + A validar calendário... + + + Editar subscrição + Cancelar + Remover subscrição + Removida subscrição com sucesso + Ignorar + Operação sem sucesso + Deseja remover a subscrição deste calendário? + Salvar + Modificações guardadas + Enviar endereço + Sincronizar este calendário + Existem modificações não guardadas + + Erro de sincronização + Salvar + Intervalo de sincronização: + + Apenas manualmente + A cada 15 minutos + A cada hora + A cada 2 horas + A cada 4 horas + Diariamente + Semanalmente + + Permissão necessária + Necessária permissão para sincronizar o calendário + + Informação da aplicação + Versão %1$s-%2$s + Doar + Subscrever feed Webcal + Notícias e updates + Sítio web + Informação Open-Source + Agradecemos o voto de confiança ao usar ICSx⁵, um software open-source (GPLv3). Porque desenvolver e manter ICSx⁵ foi e continua a ser bastante trabalhoso, por favor considere um donativo. + Mostrar página de donativos + Talvez mais tarde + diff --git a/app/src/main/res/values-ru-rUA/strings.xml b/app/src/main/res/values-ru-rUA/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..39ebc039fb5fdba3768f37cc90fe92c06f763a4c --- /dev/null +++ b/app/src/main/res/values-ru-rUA/strings.xml @@ -0,0 +1,69 @@ + + + Подписки на календари + + Далее + Разрешить доступ к календарю + Проблемы с синхронизацией + + Мои подписки + Для подписки на Webcal ленту, используйте кнопку + или откройте Webcal URL-ссылку. + Об ICSx⁵ + ещё не синхронизировано + Задать интервал синхронизации + Батарея: добавьте ICSx⁵ в исключения для частой синхронизации + Настройки + + + Подписаться на календарь + Требуется действительный URI + Ваши данные легко могут быть перехвачены третьими лицами. Используйте HTTPS  для безопасной аутентификации. + Подписаться сейчас + Подписка выполнена успешно + Пароль + Требуется аутентификация + Заглавие & Цвет + Название календаря + Имя пользователя + Проверка источника календаря... + + + Изменить подписку + Отмена + Отписаться + Подписка отменена + Убрать + Не удалось выполнить + Уверены, что хотите отменить подписку на этот календарь? + Сохранить + Изменения сохранены + Отправить URL-ссылку + Синхронизировать этот календарь + Имеются несохранённые изменения. + + Ошибка синхронизации + Сохранить + Задать интервал синхронизации: + + Только вручную + Каждые 15 минут + Каждый час + Каждые 2 часа + Каждые 4 часа + Раз в день + Раз в неделю + + Необходим доступ + Необходимо разрешение для синхронизации вашего календаря + + Информация о приложении + Версия %1$s-%2$s + Пожертвовать + Подписаться на Webcal календари + Новости & обновления + Веб страница + Сведения об открытом коде + Мы рады тому, что вы используете приложение ICSx⁵, которое представляет собой программное обеспечение с открытым кодом (GPLv3). По причине того, что было потрачено, и ещё необходимо, много усилий для разработки и обслуживания ICSx⁵, пожалуйста, рассмотрите возможность пожертвования. + Показать страницу для пожертвования + Возможно, позже + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a40628bb268197b5eaf923d0069c9c4c755ddd32..4677cfd5bb1778d3b84a06b503357db2d81af53c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,8 +4,13 @@ Далее Необходимы разрешения для работы с календарем - Проблемы с синхронизацией + Требуются разрешения на отправку уведомлений Не удалось загрузить календарь + Браузер не установлен + Проблемы с синхронизацией + Требуются разрешения + Предоставить + Пожалуйста, установите браузер Мои подписки Для подписки на ленту Webcal используйте кнопку + или откройте веб-адрес Webcal. @@ -17,9 +22,11 @@ Автоматическая синхронизация отключена на уровне системы Активировать Батарея: внесите Web Calendar Manager в белый список для использования небольших интервалов синхронизации + Синхронизировать сейчас Настройки Темная тема + Политика конфиденциальности Подписаться на календарь Требуется корректный URI @@ -31,11 +38,22 @@ Название и цвет Название календаря Введите адрес Webcal: - https://example.com/webcal.ics Или выберите файл из локального хранилища. Выбрать файл Имя пользователя Проверка доступности календаря... + Оповещения + Игнорировать оповещения, встроенные в календарь + Если включено, все входящие оповещения с сервера будут отклонены. + Добавить оповещение по умолчанию для всех событий + Добавьте оповещение по умолчанию для событий на весь день + Оповещение за %s до начала + Нет оповещений по умолчанию + Добавить оповещение по умолчанию + Это добавит оповещение для всех событий + За несколько минут до события + Установить + Введите действительное число Поделиться информацией @@ -70,17 +88,13 @@ Информация о приложении Версия %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> + https://www.gnu.org/licenses/. + \"]]> Пожертвовать Подписаться на календари Webcal Новости и обновления diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..bedb8c720c89c6c40f6ab5b0bf81577da932b49e --- /dev/null +++ b/app/src/main/res/values-si/strings.xml @@ -0,0 +1,33 @@ + + + + ඊලඟ + දින දසුන් අවසරය අවශ්‍යයයි + සමමුහූර්ත ගැටලු + + ICSx⁵ පිළිබඳව + සැකසුම් + + + මුරපදය + දින දසුනේ නම + පරිශීලක නාමය + + විස්තර බෙදාගන්න + + අවලංගු + සුරකින්න + වෙනස්කම් සුරැකිණි + ඒ.ස.නි. යවන්න + + සුරකින්න + අවසරය අවශ්‍යයයි + + යෙදුමේ තොරතුරු + අනුවාදය %1$s-%2$s + පරිත්‍යාග + පුවත් යාවත්කාල + වියමන අඩවිය + පරිත්‍යාග පිටුව පෙන්වන්න + සමහරවිට පසුව + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml similarity index 99% rename from app/src/main/res/values-uk/strings.xml rename to app/src/main/res/values-uk-rUA/strings.xml index e21af1d726b508f7ec1059d60a45b1ad6743aedb..d752d42e8441680b7ad64ae950be9fca05caac93 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -60,6 +60,7 @@ Потрібен дозвіл для синхронізації вашого календаря Інформація про додаток + Пожертва Новини & оновлення Веб-сторінка Інформація про відкритий код diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2ba9b81a40a517f66b9ed58bdacf968d32fa167b..9f6aa9bf2ec24f9618f6e4f0bc6d60028525c610 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,8 +4,13 @@ 继续 请授予访问日历的权限 - 同步问题 + 需要通知权限 无法加载日历 + 未安装浏览器 + 同步问题 + 需要权限 + 授予 + 请安装网络浏览器 我的订阅 如需订阅Webcal源,请点击 + 按钮或打开Webcal的URL地址 @@ -17,9 +22,11 @@ 系统层面的自动同步已关闭 启用 电池:请将Web Calendar Manager列入允许频繁同步的白名单 + 立即同步 设置 强制深色主题 + 隐私政策 订阅至日历 URI无效 @@ -31,11 +38,22 @@ 标题 & 颜色 日历名称 输入 Webcal 地址 - https://example.com/webcal.ics 或者从本地存储选择一个文件 选择文件 用户名 正在验证日历… + 闹铃 + 忽略嵌入在日历中的警报 + 一旦启用,所有之后来自该服务器的闹铃都将被无视。 + 为所有事件添加默认闹铃 + 为全天事件添加默认闹铃 + 开始前 %s响铃 + 无默认闹铃 + 添加默认闹铃 + 这会为所有事件添加一个闹铃 + 事件开始多少分钟 + 设置 + 输入的数字无效 分享详情 @@ -70,20 +88,20 @@ 应用信息 版本 %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> +You should have received a copy of the GNU General Public License +along with this program. If not, see https://www.gnu.org/licenses/. + \"]]> 捐赠 订阅Webcal源 新闻 & 更新 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d5ee907472e0465058f98ab6d7cb7f9e423076a..92808c41a63ef262b2674e733e7a1df45c5c6789 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,13 @@ Next Calendar permissions required - Sync problems + Notification permissions required Couldn\'t load calendar + No browser installed + Sync problems + Permissions required + Grant + Please install a Web browser My subscriptions @@ -21,10 +26,12 @@ Automatic sync disabled System-wide automatic sync is disabled Activate + Synchronize now Battery: Whitelist Web Calendar Manager for short sync intervals Settings Force dark theme + Privacy policy Subscribe to calendar @@ -37,11 +44,23 @@ Title & Color Calendar name Enter a Webcal address: - https://example.com/webcal.ics + https://example.com/webcal.ics Alternatively, select a file from local storage. Pick file User name Validating calendar resource… + Alarms + Ignore alerts embed in the calendar + If enabled, all the incoming alarms from the server will be dismissed. + Add a default alarm for all events + Add a default alarm for all-day events + Alarm %s before start + No default alarm + Add default alarm + This will add an alarm for all events + Minutes before event + Set + Introduce a valid number Share details @@ -89,8 +108,9 @@ App info Version %1$s-%2$s + Web Calendar Manager is forked from ICSx⁵ GPLv3 - https://www.gnu.org/licenses/. - ]]> + "]]> Donate Subscribe to Webcal feeds News & updates diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ecb6f20ca42e57563ae279856a61051984e6467d..3167b2622416dd8dd2bb7b12487ad2b9a7ed0548 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,16 +1,15 @@ - #039be5 - #01579b + @color/e_accent + @color/e_accent - #ff2200 + @color/e_error - \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000000000000000000000000000000000..3d047e8123f9fb7f9fb7a8c8aeafd9376143cc2e --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000000000000000000000000000000000..f162389c878d229d8b4f9cf9df46747733dab05a --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index c4277246b084b48062169a8d04c9bc7efa50d4ea..6c77c037868dfed676c76f51666647dc1184aa0e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,21 @@ buildscript { ext.versions = [ - kotlin: '1.7.10', - okhttp: '5.0.0-alpha.10' + composeBom: '2023.05.01', // https://developer.android.com/jetpack/compose/bom + kotlin: '1.8.21', // keep in sync with app/build.gradle composeOptions.kotlinCompilerExtensionVersion + ksp: '1.0.11', + okhttp: '5.0.0-alpha.11', + room: '2.5.1' ] repositories { google() - maven { - url "https://plugins.gradle.org/m2/" - } mavenCentral() + maven { url "https://plugins.gradle.org/m2/" } } dependencies { - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:8.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" + classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" } } @@ -21,5 +23,7 @@ allprojects { repositories { google() mavenCentral() + maven { url "https://jitpack.io" } + maven { url 'https://gitlab.e.foundation/api/v4/groups/9/-/packages/maven'} } } \ No newline at end of file diff --git a/cert4android b/cert4android deleted file mode 160000 index b3e28100d7b349c360f3537f5856fc486bf73148..0000000000000000000000000000000000000000 --- a/cert4android +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b3e28100d7b349c360f3537f5856fc486bf73148 diff --git a/gradle.properties b/gradle.properties index 69d2d97e7e0e5df0eadb2fae13069daf98393445..cf4a0eaf4e87065c42f6fc613e75edabdb8ae3e0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,11 @@ +# [https://developer.android.com/build/optimize-your-build#optimize] org.gradle.parallel=true -org.gradle.jvmargs=-Xmx4g +org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=512m # use AndroidX android.useAndroidX=true android.databinding.incremental=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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 92f06b50fd65b47b5578f5427b9fee66dcaae5ae..6b8b9ffdcf6495ba236af26fa4f34ed7d8976c75 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Tue Jul 25 20:34:04 BDT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ical4android b/ical4android deleted file mode 160000 index 86e0d7ccf54caca48c361b2dd27199ecac5149f4..0000000000000000000000000000000000000000 --- a/ical4android +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 86e0d7ccf54caca48c361b2dd27199ecac5149f4 diff --git a/libs/ical4android.aar b/libs/ical4android.aar new file mode 100644 index 0000000000000000000000000000000000000000..2d12af75a8a82068725dd23ae37a98ce6ad7d87f Binary files /dev/null and b/libs/ical4android.aar differ diff --git a/scripts/fetch-translations.sh b/scripts/fetch-translations.sh new file mode 100755 index 0000000000000000000000000000000000000000..9b9162b22951164fc1ea5d05f655ef38c45ada9f --- /dev/null +++ b/scripts/fetch-translations.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# Important! Use the new client: https://github.com/transifex/cli/ +# The old one [https://github.com/transifex/transifex-client/] which is still packaged with Ubuntu 22.10 doesn't work anymore since Nov 2022 + +MYDIR=`dirname $0`/.. +cd $MYDIR +tx pull --use-git-timestamps -a --minimum-perc 10 diff --git a/settings.gradle b/settings.gradle index 15bc48156210e005c0f5c4d901d3dea51c98874d..7820c8ffbc9306351190d77447a99bf983af9cd8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,7 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} -include ':app', ':cert4android', ':ical4android' +include ':app'