From f978c3727cdbdd72631cb6870f0fdaad5e066fae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 21:19:52 +0200 Subject: [PATCH] feat(provider): ship our own task store, vendored under our own authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 2 and 3 of docs/STORAGE-AND-SYNC.md. Agendula stops depending on a tasks provider app being installed: it now carries one. The module New :provider — the dmfs task provider 1.4.2 (Apache-2.0, DB 23), vendored in-tree, renamed to authority de.jeanlucmakiola.agendula.tasks and permissions de.jeanlucmakiola.agendula.permission.*. It coexists with OpenTasks and tasks.org rather than replacing them; nothing collides with org.dmfs.*, so both can be installed at once. The contract shape is untouched — same tables, same columns — because that is what our data layer and every CalDAV engine already speak. We own the namespace it lives in, not the schema. Vendored rather than depended on because the permission names are hardcoded in the upstream AAR's manifest and cannot be renamed in a prebuilt artifact; in-tree also satisfies F-Droid's from-source rule. provider/PROVENANCE.md records the upstream commit and every deviation, each marked with an AGENDULA CHANGE comment at the site so the list and the code cannot drift apart. The change that matters most is the account cleanup. Upstream holds GET_ACCOUNTS and deletes any task list whose account it cannot see. We dropped that permission — we only ever need our own accounts, which are visible without it — but an account we cannot see is indistinguishable from one that was removed, so left alone the provider would quietly delete synced lists. Cleanup is now restricted to account types this package authenticates itself, which is currently none. ProviderAccountCleanupTest pins that, and answers open question 3: the local path works with no account present at all. Also required by targetSdk 36, none of which upstream faced at 29: FLAG_IMMUTABLE on the notification PendingIntent, an inexact-alarm fallback so a revoked SCHEDULE_EXACT_ALARM cannot kill the app on a timezone change, and an explicit android:exported on the receiver. Storage modes ProviderResolver gains a StorageMode: LOCAL (our provider) or EXTERNAL (an installed one). Not a third SYNCED value — synced is LOCAL with an account attached, which is derived state, and modelling it as a separate store would imply switching sync on is a migration. It isn't. When the user has not chosen, the tell is whether we already hold an external provider's runtime permission. That permission is dangerous-level, so it can only be there because an earlier version asked and they agreed — the signature of an existing Posture A user, who must not be dropped onto an empty store. Fresh installs get local-first. hasPermission now short-circuits for our own provider: same-uid access bypasses the check outright, so ProviderStatus.NEEDS_PERMISSION can no longer fire in Local mode. That was the work item the storage-and-sync doc called for. The resolver's platform calls moved behind ProviderEnvironment so the decision — the part that loses people their data if wrong — is unit-tested on the JVM. Verified: 51 vendored provider tests pass, app tests pass, lintDebug and assembleDebug clean. ProviderAccountCleanupTest skips on ARM64, where Robolectric has no SQLite backend, and runs on x86_64 CI. Not yet exercised on a device. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 17 +- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 21 +- .../agendula/data/di/DataModule.kt | 15 + .../agendula/data/di/Qualifiers.kt | 10 + .../agendula/data/prefs/SettingsPrefs.kt | 19 + .../data/tasks/ProviderEnvironment.kt | 51 + .../agendula/data/tasks/ProviderResolver.kt | 126 +- .../agendula/data/tasks/StorageMode.kt | 30 + .../agendula/data/tasks/StorageModeHolder.kt | 47 + .../ui/permission/PermissionViewModel.kt | 10 + .../data/tasks/ProviderResolverTest.kt | 161 ++ build.gradle.kts | 1 + gradle/libs.versions.toml | 27 + provider/LICENSE | 202 ++ provider/NOTICE | 2 + provider/PROVENANCE.md | 192 ++ provider/build.gradle.kts | 66 + provider/proguard-rules.pro | 25 + provider/src/main/AndroidManifest.xml | 72 + .../java/org/dmfs/ngrams/NGramGenerator.java | 168 ++ .../dmfs/provider/tasks/AuthorityUtil.java | 43 + .../dmfs/provider/tasks/ContentOperation.java | 419 ++++ .../provider/tasks/FTSDatabaseHelper.java | 630 ++++++ .../provider/tasks/ProviderOperation.java | 39 + .../provider/tasks/SQLiteContentProvider.java | 364 ++++ .../provider/tasks/TaskDatabaseHelper.java | 895 +++++++++ .../org/dmfs/provider/tasks/TaskProvider.java | 1408 ++++++++++++++ .../tasks/TaskProviderBroadcastReceiver.java | 134 ++ .../java/org/dmfs/provider/tasks/Utils.java | 214 ++ .../provider/tasks/handler/AlarmHandler.java | 133 ++ .../tasks/handler/CategoryHandler.java | 277 +++ .../tasks/handler/DefaultPropertyHandler.java | 54 + .../tasks/handler/PropertyHandler.java | 155 ++ .../tasks/handler/PropertyHandlerFactory.java | 61 + .../tasks/handler/RelationHandler.java | 276 +++ .../tasks/model/AbstractInstanceAdapter.java | 37 + .../tasks/model/AbstractListAdapter.java | 56 + .../tasks/model/AbstractTaskAdapter.java | 71 + .../model/ContentValuesInstanceAdapter.java | 161 ++ .../tasks/model/ContentValuesListAdapter.java | 130 ++ .../tasks/model/ContentValuesTaskAdapter.java | 132 ++ .../CursorContentValuesInstanceAdapter.java | 212 ++ .../model/CursorContentValuesListAdapter.java | 139 ++ .../model/CursorContentValuesTaskAdapter.java | 169 ++ .../provider/tasks/model/EntityAdapter.java | 151 ++ .../provider/tasks/model/InstanceAdapter.java | 109 ++ .../provider/tasks/model/ListAdapter.java | 82 + .../provider/tasks/model/TaskAdapter.java | 362 ++++ .../model/adapters/BinaryFieldAdapter.java | 95 + .../model/adapters/BooleanFieldAdapter.java | 94 + .../model/adapters/DateTimeFieldAdapter.java | 243 +++ .../DateTimeIterableFieldAdapter.java | 198 ++ .../model/adapters/DurationFieldAdapter.java | 106 + .../tasks/model/adapters/FieldAdapter.java | 148 ++ .../model/adapters/FloatFieldAdapter.java | 95 + .../model/adapters/IntegerFieldAdapter.java | 96 + .../model/adapters/LongFieldAdapter.java | 94 + .../model/adapters/RRuleFieldAdapter.java | 122 ++ .../model/adapters/SimpleFieldAdapter.java | 100 + .../model/adapters/StringFieldAdapter.java | 95 + .../tasks/model/adapters/UrlFieldAdapter.java | 95 + .../tasks/processors/EntityProcessor.java | 35 + .../provider/tasks/processors/Logging.java | 67 + .../tasks/processors/NoOpProcessor.java | 50 + .../tasks/processors/instances/Detaching.java | 337 ++++ .../instances/TaskValueDelegate.java | 284 +++ .../processors/instances/Validating.java | 186 ++ .../processors/lists/ListCommitProcessor.java | 56 + .../tasks/processors/lists/Validating.java | 137 ++ .../processors/tasks/AutoCompleting.java | 210 ++ .../tasks/processors/tasks/Instantiating.java | 397 ++++ .../tasks/processors/tasks/Moving.java | 205 ++ .../tasks/processors/tasks/Originating.java | 77 + .../tasks/processors/tasks/Relating.java | 150 ++ .../tasks/processors/tasks/Reparenting.java | 119 ++ .../tasks/processors/tasks/Searchable.java | 66 + .../processors/tasks/TaskCommitProcessor.java | 67 + .../tasks/processors/tasks/Validating.java | 278 +++ .../processors/tasks/instancedata/Dated.java | 51 + .../tasks/instancedata/Distant.java | 43 + .../tasks/instancedata/DueDated.java | 39 + .../tasks/instancedata/Enduring.java | 59 + .../tasks/instancedata/Overridden.java | 62 + .../tasks/instancedata/StartDated.java | 39 + .../tasks/instancedata/TaskRelated.java | 57 + .../instancedata/VanillaInstanceData.java | 46 + .../provider/tasks/utils/ContainsValues.java | 72 + .../tasks/utils/InstanceValuesIterable.java | 120 ++ .../dmfs/provider/tasks/utils/Limited.java | 49 + .../provider/tasks/utils/LimitedIterator.java | 63 + .../tasks/utils/OverrideValuesFunction.java | 64 + .../dmfs/provider/tasks/utils/Profiled.java | 80 + .../org/dmfs/provider/tasks/utils/Range.java | 56 + .../provider/tasks/utils/ResourceArray.java | 49 + .../provider/tasks/utils/RowIterator.java | 57 + .../provider/tasks/utils/TableColumns.java | 61 + .../tasks/utils/TaskInstanceIterable.java | 80 + .../tasks/utils/TaskInstanceIterator.java | 78 + .../dmfs/provider/tasks/utils/Timestamps.java | 55 + .../org/dmfs/provider/tasks/utils/With.java | 64 + .../org/dmfs/provider/tasks/utils/Zipped.java | 43 + .../org/dmfs/tasks/contract/TaskContract.java | 1728 +++++++++++++++++ .../org/dmfs/tasks/contract/UriFactory.java | 57 + .../res/drawable/ic_24_agendula_tasks.xml | 4 + provider/src/main/res/values-cs/strings.xml | 14 + provider/src/main/res/values-de/strings.xml | 14 + provider/src/main/res/values-es/strings.xml | 14 + provider/src/main/res/values-fr/strings.xml | 14 + provider/src/main/res/values-hu/strings.xml | 14 + provider/src/main/res/values-it/strings.xml | 14 + provider/src/main/res/values-ja/strings.xml | 14 + provider/src/main/res/values-nl/strings.xml | 14 + provider/src/main/res/values-pl/strings.xml | 14 + .../src/main/res/values-pt-rBR/strings.xml | 14 + .../src/main/res/values-pt-rPT/strings.xml | 14 + provider/src/main/res/values-ru/strings.xml | 16 + provider/src/main/res/values-sr/strings.xml | 14 + provider/src/main/res/values-uk/strings.xml | 14 + .../src/main/res/values/agendula_defaults.xml | 16 + .../agendula_provider_changed_receivers.xml | 15 + provider/src/main/res/values/strings.xml | 20 + .../tasks/ProviderAccountCleanupTest.java | 215 ++ .../DateTimeIterableFieldAdapterTest.java | 223 +++ .../tasks/instancedata/DatedTest.java | 62 + .../tasks/instancedata/DistantTest.java | 46 + .../tasks/instancedata/DueDatedTest.java | 84 + .../tasks/instancedata/EnduringTest.java | 80 + .../tasks/instancedata/OverriddenTest.java | 110 ++ .../tasks/instancedata/StartDatedTest.java | 84 + .../tasks/instancedata/TaskRelatedTest.java | 44 + .../instancedata/VanillaInstanceDataTest.java | 53 + .../tasks/utils/ContainsValuesTest.java | 94 + .../tasks/utils/ContentValuesWithLong.java | 57 + .../tasks/utils/TaskInstanceIterableTest.java | 188 ++ .../tasks/utils/TaskInstanceIteratorTest.java | 121 ++ .../dmfs/provider/tasks/utils/ZippedTest.java | 60 + .../src/test/resources/robolectric.properties | 26 + settings.gradle.kts | 5 + 139 files changed, 17424 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt create mode 100644 provider/LICENSE create mode 100644 provider/NOTICE create mode 100644 provider/PROVENANCE.md create mode 100644 provider/build.gradle.kts create mode 100644 provider/proguard-rules.pro create mode 100644 provider/src/main/AndroidManifest.xml create mode 100644 provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/Utils.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/With.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java create mode 100644 provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java create mode 100644 provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java create mode 100644 provider/src/main/res/drawable/ic_24_agendula_tasks.xml create mode 100644 provider/src/main/res/values-cs/strings.xml create mode 100644 provider/src/main/res/values-de/strings.xml create mode 100644 provider/src/main/res/values-es/strings.xml create mode 100644 provider/src/main/res/values-fr/strings.xml create mode 100644 provider/src/main/res/values-hu/strings.xml create mode 100644 provider/src/main/res/values-it/strings.xml create mode 100644 provider/src/main/res/values-ja/strings.xml create mode 100644 provider/src/main/res/values-nl/strings.xml create mode 100644 provider/src/main/res/values-pl/strings.xml create mode 100644 provider/src/main/res/values-pt-rBR/strings.xml create mode 100644 provider/src/main/res/values-pt-rPT/strings.xml create mode 100644 provider/src/main/res/values-ru/strings.xml create mode 100644 provider/src/main/res/values-sr/strings.xml create mode 100644 provider/src/main/res/values-uk/strings.xml create mode 100644 provider/src/main/res/values/agendula_defaults.xml create mode 100644 provider/src/main/res/values/agendula_provider_changed_receivers.xml create mode 100644 provider/src/main/res/values/strings.xml create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java create mode 100644 provider/src/test/resources/robolectric.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9698ad7..fd13d5c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,6 +138,11 @@ kotlin { } dependencies { + // Agendula's own task store — the dmfs provider vendored under our authority. + // Contributes a to the merged manifest; no app code imports from it + // except ProviderResolver, which reads the authority out of its resources. + implementation(project(":provider")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d37b60..130f919 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,15 @@ - + runtime by the permission flow, and only once the user has actually + selected External mode. Both are dangerous-level. + + Agendula's own bundled provider needs NO entry here: it runs under our uid + and a same-uid caller bypasses a provider's permission checks outright. + Its permissions are declared by the :provider module, for other apps. --> @@ -74,13 +80,16 @@ - + + diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 000182b..0adb920 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler +import de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -35,17 +36,27 @@ class AgendulaApp : Application() { issueTitle = getString(R.string.crash_report_issue_title), ), ) - val scheduler = EntryPointAccessors - .fromApplication(this, ReminderEntryPoint::class.java) - .reminderScheduler() + val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) + val scheduler = entryPoint.reminderScheduler() + // Start mirroring the stored storage mode into ProviderResolver before + // anything reads a provider. + val storageModeHolder = entryPoint.storageModeHolder() + storageModeHolder.start() CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - runCatching { scheduler.sync() } + // Wait for the stored mode to land first. Rescheduling alarms against + // whichever provider autoMode happens to pick would arm them off the + // wrong store for a user who chose the other one. + runCatching { + storageModeHolder.awaitReady() + scheduler.sync() + } } } @EntryPoint @InstallIn(SingletonComponent::class) - interface ReminderEntryPoint { + interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler + fun storageModeHolder(): StorageModeHolder } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 7e668e7..8bf97b6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -10,12 +10,16 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton private val Context.agendulaDataStore: DataStore by preferencesDataStore( @@ -33,6 +37,10 @@ abstract class DataBindModule { @Binds @Singleton abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository + + @Binds + @Singleton + abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment } @Module @@ -47,4 +55,11 @@ object DataProvideModule { @Provides @IoDispatcher fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + // SupervisorJob so one failing collector can't take the others down with it. + CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index 6be87bc..e42dfcf 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -6,3 +6,13 @@ import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher + +/** + * Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that + * outlives any screen and has nothing to be cancelled by, such as keeping the + * selected storage mode mirrored out of DataStore. It is never cancelled, so + * don't launch anything unbounded in it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 4508baf..1644fc5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec @@ -82,6 +83,23 @@ class SettingsPrefs @Inject constructor( suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes } + /** + * Which task store backs the app, or `null` while the user has not chosen — + * which is the normal state, since most people never open Settings. + * + * Kept out of [Settings] on purpose. Everything in there is a rendering + * preference collected by the UI; this one selects an authority in the data + * layer, is read on paths that must not wait for a whole settings object, and + * `null` genuinely means "undecided" rather than "default" — the difference + * matters, because undecided is what lets `ProviderResolver.autoMode` keep an + * upgrading Posture A user pointed at the provider that holds their data. + */ + val storageMode: Flow = dataStore.data.map { p -> + p[STORAGE_MODE]?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } + } + + suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } + /** One-time reminder onboarding gate; false until the step has been shown. */ val reminderOnboardingDone: Flow = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false } @@ -113,6 +131,7 @@ class SettingsPrefs @Inject constructor( val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") + val STORAGE_MODE = stringPreferencesKey("storage_mode") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt new file mode 100644 index 0000000..4bf4c9e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The three platform facts [ProviderResolver] needs, behind an interface. + * + * Same seam the data source uses, for the same reason: which store a returning + * user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows + * them an empty app, and that decision is worth testing on the JVM rather than + * only on a device. Everything Android-shaped lives here so the logic above stays + * plain Kotlin. + */ +interface ProviderEnvironment { + + /** Our own bundled provider's authority, from the `:provider` module's resources. */ + val ownAuthority: String + + /** Our own package name. */ + val ownPackage: String + + /** The package declaring [authority], or `null` when nothing on the device does. */ + fun packageDeclaring(authority: String): String? + + /** Whether this app currently holds [permission]. */ + fun isGranted(permission: String): Boolean +} + +@Singleton +class AndroidProviderEnvironment @Inject constructor( + @ApplicationContext private val context: Context, +) : ProviderEnvironment { + + override val ownAuthority: String + // Read from the module that declares it, never written as a literal: the + // authority lives in exactly one place, its own string resource. + get() = context.getString(de.jeanlucmakiola.agendula.provider.R.string.agendula_tasks_authority) + + override val ownPackage: String get() = context.packageName + + override fun packageDeclaring(authority: String): String? = + context.packageManager.resolveContentProvider(authority, 0)?.packageName + + override fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index ba24eb0..da2e700 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -1,59 +1,133 @@ package de.jeanlucmakiola.agendula.data.tasks -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton /** * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every - * candidate, so the [TasksContract] columns apply regardless of which is present. + * candidate — ours included, since `:provider` *is* that provider vendored — so + * the [TasksContract] columns apply regardless of which is active. */ data class TaskProvider( val authority: String, val readPermission: String, val writePermission: String, val packageName: String? = null, + /** + * True for Agendula's own bundled provider. It runs in our process under our + * uid, and a same-uid caller bypasses a provider's permission checks outright, + * so [ProviderResolver.hasPermission] must never gate on a grant for it. + */ + val isOwn: Boolean = false, ) /** - * The A/B seam. Detects which tasks provider is installed at runtime and which - * permission set it needs, so nothing above the data layer hardcodes an - * authority. Under Posture B (bundled provider) this simply finds our own - * `org.dmfs.tasks` first. See docs/PLAN.md. + * The A/B seam: the only class in the app that knows an authority exists. + * + * - **Posture A** — an *external* provider (OpenTasks, tasks.org). Still fully + * supported; it stopped being the default and became a user choice. + * - **Posture B** — our own bundled provider, under our **own** authority. It + * coexists with everything and replaces nothing. + * + * Both terms were redefined by `docs/STORAGE-AND-SYNC.md`. Posture B used to mean + * "bundle OpenTasks and squat `org.dmfs.tasks`"; that is a dead end and is not + * coming back, because two apps cannot declare the same authority + * (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same permission name + * (`INSTALL_FAILED_DUPLICATE_PERMISSION`) — anyone with OpenTasks installed would + * simply have been unable to install Agendula. + * + * Which one is active comes from [storageMode]; how that gets decided when the + * user has not chosen is [autoMode]. */ @Singleton class ProviderResolver @Inject constructor( - @ApplicationContext private val context: Context, + private val environment: ProviderEnvironment, ) { - /** The active provider, or `null` when no tasks provider is installed. */ - fun resolve(): TaskProvider? { - for (candidate in CANDIDATES) { - val info = context.packageManager.resolveContentProvider(candidate.authority, 0) - ?: continue - return candidate.copy(packageName = info.packageName) + /** + * The user's explicit choice, or `null` while they have not made one (which is + * the normal state — most people never open Settings). Kept as a plain field + * rather than read from DataStore on demand because [resolve] is called from + * synchronous data-source code on every query, including from the main thread + * via `providerStatus()`. [StorageModeHolder] owns keeping it current. + */ + @Volatile + var storageMode: StorageMode? = null + + /** Agendula's own provider. Always present — it ships inside the APK. */ + val own: TaskProvider by lazy { + TaskProvider( + authority = environment.ownAuthority, + readPermission = OWN_READ_PERMISSION, + writePermission = OWN_WRITE_PERMISSION, + packageName = environment.ownPackage, + isOwn = true, + ) + } + + /** The active provider, or `null` when [StorageMode.EXTERNAL] is chosen and none is installed. */ + fun resolve(): TaskProvider? = when (storageMode ?: autoMode()) { + StorageMode.LOCAL -> own + StorageMode.EXTERNAL -> resolveExternal() + } + + /** + * What to use when the user has not chosen — and the one piece of real + * judgement in this class, because getting it wrong loses people their data. + * + * Ranking our own provider first unconditionally would be wrong: someone who + * has been using Agendula over OpenTasks since 0.3.x would update, land on an + * empty bundled store, and reasonably conclude their tasks were deleted. + * + * So the tell is **whether we already hold an external provider's runtime + * permission**. That is a dangerous permission — it can only be there because + * a previous version asked and the user agreed, which is precisely the + * definition of "this person is an existing Posture A user". A fresh install + * never holds it, and gets local-first storage. + * + * Deliberately cheap and synchronous: a PackageManager lookup and a permission + * check, no database probe. Settings overrides it either way. + */ + fun autoMode(): StorageMode { + val external = resolveExternal() + return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.LOCAL + } + + /** The first installed external candidate, or `null` when none is present. */ + fun resolveExternal(): TaskProvider? { + for (candidate in EXTERNAL_CANDIDATES) { + val packageName = environment.packageDeclaring(candidate.authority) ?: continue + return candidate.copy(packageName = packageName) } return null } fun hasPermission(provider: TaskProvider): Boolean = - granted(provider.readPermission) && granted(provider.writePermission) - - private fun granted(permission: String): Boolean = - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + // Same uid, same process: there is nothing to grant, and asking would put a + // permission dialog in front of a purely local app for no reason. This is + // the bypass docs/STORAGE-AND-SYNC.md calls for — without it + // ProviderStatus.NEEDS_PERMISSION fires in Local mode and the onboarding + // gate asks for a permission that can never be granted. + provider.isOwn || + (environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)) companion object { /** - * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by - * `org.dmfs.provider.tasks.TaskProvider`, guarded by - * `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks` - * + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical - * authority; on a device with only one installed, order is moot. + * Declared by the `:provider` module's manifest. Listed here so the app can + * name them; nothing ever requests them, since [hasPermission] short-circuits + * for our own provider. */ - val CANDIDATES: List = listOf( + const val OWN_READ_PERMISSION = "de.jeanlucmakiola.agendula.permission.READ_TASKS" + const val OWN_WRITE_PERMISSION = "de.jeanlucmakiola.agendula.permission.WRITE_TASKS" + + /** + * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by + * `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*` + * (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`. + * OpenTasks is listed first as the canonical authority; on a device with + * only one installed, order is moot. + */ + val EXTERNAL_CANDIDATES: List = listOf( TaskProvider( authority = "org.dmfs.tasks", readPermission = "org.dmfs.permission.READ_TASKS", diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt new file mode 100644 index 0000000..85789d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.data.tasks + +/** + * Which task store backs the app — the user's choice, per `docs/STORAGE-AND-SYNC.md`. + * + * Only two values, though the document describes three modes. **Synced is not a + * third store**: it is [LOCAL] with an account attached, so it is derived state + * (does an account of ours exist?) rather than something the user picks. That + * also means switching sync on is never a migration. Adding a `SYNCED` constant + * here would imply otherwise. + * + * Nothing above the data layer reads this; it selects an authority for + * [ProviderResolver] and stops there. + */ +enum class StorageMode { + /** + * Agendula's own bundled provider (the `:provider` module). Always available — + * it ships in the APK — and needs no permission grant at all, because + * same-uid access to your own provider skips the permission check entirely. + */ + LOCAL, + + /** + * A tasks provider app already on the device (OpenTasks, tasks.org), synced by + * whatever that provider's engine is — DAVx5 and friends. This is the original + * Posture A, still fully supported, but now a choice rather than the only way. + * Requires that provider's runtime read/write permissions. + */ + EXTERNAL, +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt new file mode 100644 index 0000000..81b86dc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Mirrors the stored [StorageMode] into [ProviderResolver]. + * + * The resolver is consulted synchronously from every data-source call and from + * `providerStatus()` on the main thread, so it cannot read DataStore itself. + * This is the one component that bridges the two: it collects the preference for + * the life of the process and pushes each value across. + * + * [awaitReady] exists for the startup race. Until the first DataStore emission + * arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers + * instead — fine as a steady state, wrong for a user who explicitly chose the + * other mode. Anything that touches the provider before the UI is up (the launch + * reminder re-sync, notably) should wait rather than risk reading the wrong + * store and rescheduling every alarm off it. + */ +@Singleton +class StorageModeHolder @Inject constructor( + private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val firstValue = CompletableDeferred() + + /** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */ + fun start() { + scope.launch { + prefs.storageMode.collect { mode -> + resolver.storageMode = mode + firstValue.complete(Unit) + } + } + } + + /** Suspends until the stored mode has been applied at least once. */ + suspend fun awaitReady() = firstValue.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 9e6760a..9186e16 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -20,6 +20,10 @@ data class PermissionUiState( * Gates app entry: is a tasks provider installed, and do we hold its permissions? * The Composable owns the actual permission-launcher and store intents; this VM * supplies the [status] and the exact permission strings to ask for. + * + * In the default Local mode this gate never appears at all — Agendula's own + * provider ships in the APK and is reached same-uid, so there is nothing to + * install and nothing to grant. It exists for External mode. */ @HiltViewModel class PermissionViewModel @Inject constructor( @@ -37,7 +41,13 @@ class PermissionViewModel @Inject constructor( val provider = providerResolver.resolve() _state.value = PermissionUiState( status = repository.providerStatus(), + // Never our own provider's permissions. They are declared for *other* + // apps to hold; requesting them here would show a dialog for a + // permission that a same-uid caller does not need and the system will + // not meaningfully grant. In practice this branch is unreachable in + // Local mode, since the status is already READY — belt and braces. permissionsToRequest = provider + ?.takeUnless { it.isOwn } ?.let { listOf(it.readPermission, it.writePermission) } .orEmpty(), ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt new file mode 100644 index 0000000..edf4494 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * The storage-mode decision, which is the part of [ProviderResolver] with real + * consequences: pick wrong for a returning user and the app opens on an empty + * store where their tasks used to be. + */ +class ProviderResolverTest { + + /** + * A [ProviderEnvironment] with no Android in it. + * + * @param installed authority -> declaring package, i.e. what is on the device. + * @param granted permissions this app currently holds. + */ + private class FakeEnvironment( + val installed: Map = emptyMap(), + val granted: Set = emptySet(), + ) : ProviderEnvironment { + override val ownAuthority = "de.jeanlucmakiola.agendula.tasks" + override val ownPackage = "de.jeanlucmakiola.agendula" + override fun packageDeclaring(authority: String): String? = installed[authority] + override fun isGranted(permission: String): Boolean = permission in granted + } + + private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } + + private fun resolver( + installed: Map = emptyMap(), + granted: Set = emptySet(), + mode: StorageMode? = null, + ) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode } + + private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks") + private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission) + + @Nested + inner class OwnProvider { + + @Test + fun `needs no permission grant`() { + // The bypass the whole Local mode rests on: our provider runs under our + // own uid, so there is nothing to grant and nothing to ask for. + val resolver = resolver() + assertThat(resolver.hasPermission(resolver.own)).isTrue() + } + + @Test + fun `is always resolvable because it ships in the APK`() { + assertThat(resolver(mode = StorageMode.LOCAL).resolve()).isNotNull() + } + + @Test + fun `does not use a dmfs authority`() { + // Squatting org.dmfs.tasks would make Agendula and OpenTasks mutually + // uninstallable. Guards against a careless resync of the vendored module. + assertThat(resolver().own.authority).isEqualTo("de.jeanlucmakiola.agendula.tasks") + } + } + + @Nested + inner class AutoMode { + + @Test + fun `a fresh install with nothing else present is local`() { + assertThat(resolver().autoMode()).isEqualTo(StorageMode.LOCAL) + } + + @Test + fun `an upgrading user who already granted OpenTasks stays on it`() { + // Holding a dangerous permission means a previous version asked and they + // agreed — the signature of an existing Posture A user. Sending them to + // our empty bundled store would read as data loss. + val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `OpenTasks merely installed is not enough`() { + // Someone who has OpenTasks for unrelated reasons, and never granted us + // anything, has no data with us there. Local-first is right for them. + assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.LOCAL) + } + + @Test + fun `a half-granted external provider does not count`() { + val resolver = resolver( + installed = openTasksInstalled, + granted = setOf(openTasks.readPermission), + ) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.LOCAL) + } + } + + @Nested + inner class ExplicitChoice { + + @Test + fun `overrides the automatic answer in both directions`() { + val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted) + + val forcedLocal = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.LOCAL } + assertThat(forcedLocal.resolve()?.isOwn).isTrue() + + val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL } + assertThat(forcedExternal.resolve()).isNull() + } + + @Test + fun `external with no provider installed resolves to nothing`() { + // Drives the "install a tasks provider" gate rather than silently + // falling back to our own store behind the user's back. + assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull() + } + + @Test + fun `external still requires the runtime permission`() { + val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) + val provider = resolver.resolve() + assertThat(provider).isNotNull() + assertThat(resolver.hasPermission(provider!!)).isFalse() + } + } + + @Nested + inner class ExternalCandidates { + + @Test + fun `prefer OpenTasks over tasks_org when both are installed`() { + val resolver = resolver( + installed = mapOf( + "org.dmfs.tasks" to "org.dmfs.tasks", + "org.tasks.opentasks" to "org.tasks", + ), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `fall through to tasks_org when OpenTasks is absent`() { + val resolver = resolver( + installed = mapOf("org.tasks.opentasks" to "org.tasks"), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks") + } + + @Test + fun `never include our own provider`() { + // EXTERNAL must mean "somebody else's store". If ours ever leaked into + // this list, choosing External would silently keep using it. + assertThat(ProviderResolver.EXTERNAL_CANDIDATES.none { it.isOwn }).isTrue() + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index a96f72d..d8237c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b691c2a..b72b303 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,22 @@ androidxTestRules = "1.7.0" # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). glance = "1.1.1" +# --- :provider (vendored dmfs task provider) --------------------------------- +# Versions the upstream 1.4.2 source was written against. These are its runtime +# dependencies, not ours — nothing above the data layer touches them, and they +# only move when we deliberately resync the fork. See provider/PROVENANCE.md. +dmfsJems = "1.43" +dmfsRfc5545Datetime = "0.2.4" +dmfsLibRecur = "0.12.2" +# The provider's own test suite is JUnit 4 + Robolectric, unlike the app's +# JUnit 5. Kept as upstream wrote it (rewriting ~13 test classes would forfeit +# the regression coverage that makes vendoring safe), but on current versions: +# upstream pins Robolectric 3.5.1, which predates AGP's resource handling. +robolectric = "4.16" +junit4 = "4.13.2" +hamcrest = "3.0" +mockito = "5.20.0" + [libraries] # AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -92,8 +108,19 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material # Android tests - GrantPermissionRule androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } +# :provider — vendored dmfs task provider (see provider/PROVENANCE.md) +dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" } +dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" } +dmfs-rfc5545-datetime = { group = "org.dmfs", name = "rfc5545-datetime", version.ref = "dmfsRfc5545Datetime" } +dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/provider/LICENSE b/provider/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/provider/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/provider/NOTICE b/provider/NOTICE new file mode 100644 index 0000000..6a0d99d --- /dev/null +++ b/provider/NOTICE @@ -0,0 +1,2 @@ +OpenTasks - Open Source Task App for Android +Copyright 2012-2015 Marten Gajda \ No newline at end of file diff --git a/provider/PROVENANCE.md b/provider/PROVENANCE.md new file mode 100644 index 0000000..f911df4 --- /dev/null +++ b/provider/PROVENANCE.md @@ -0,0 +1,192 @@ +# `:provider` — provenance + +This module is **not our code**. It is the dmfs task provider, vendored, with +its namespace renamed to ours and a short list of changes recorded below. + +| | | +|---|---| +| Upstream | [dmfs/opentasks](https://github.com/dmfs/opentasks) | +| Module taken | `opentasks-provider`, plus `opentasks-contract` (see [Why the contract came along](#why-the-contract-came-along)) | +| Version | `1.4.2` | +| Commit | `49ebf80b1eeee52a611e5a22f24f849852a6255f` (2021-03-21) | +| License | Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE), both upstream's, unmodified | +| Database version | **23** | + +Agendula itself is MIT. Apache-2.0 into MIT is fine in that direction, but this +module keeps its own `LICENSE`, `NOTICE`, and per-file Apache headers, and those +must survive any future edit here. + +**1.4.2 specifically, for the database version.** DB 23 is the first to carry +`is_recurring`. tasks.org's fork is DB 22 and lacks it — which is why +`TaskMapper.task` on the app side derives recurrence from `rrule`/`rdate` +instead of trusting that column, and why it must keep doing so as long as +External mode supports tasks.org. + +## Why vendored at all + +Recorded properly in [`docs/STORAGE-AND-SYNC.md`](../docs/STORAGE-AND-SYNC.md); +in one line: **the permission names are hardcoded in the upstream AAR's +manifest.** No prebuilt artifact — Maven Central, JitPack, anything — can have +them renamed without `tools:` node surgery, and shipping under dmfs's own +permission names would make Agendula and OpenTasks mutually uninstallable +(`INSTALL_FAILED_DUPLICATE_PERMISSION`). In-tree also satisfies F-Droid's +from-source requirement, which a JitPack artifact would not. + +In-tree rather than a git submodule, unlike floret-kit: we co-develop the kit, +whereas this is a fork we expect to resync from upstream approximately never. + +## The namespace rename + +Everything in this table is a rename and nothing more. The **contract shape is +untouched** — same tables, same column names, same URI paths — because that +shape is what our data layer, and every CalDAV engine, already speaks. We own +the namespace it lives in, not the schema. + +| | Upstream | Ours | +|---|---|---| +| Authority | `org.dmfs.tasks` | `de.jeanlucmakiola.agendula.tasks` | +| Read permission | `org.dmfs.permission.READ_TASKS` | `de.jeanlucmakiola.agendula.permission.READ_TASKS` | +| Write permission | `org.dmfs.permission.WRITE_TASKS` | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` | +| Permission group | `org.dmfs.tasks.permissiongroup.Tasks` | `de.jeanlucmakiola.agendula.permissiongroup.Tasks` | +| Notification-alarm action | `org.dmfs.tasks.provider.NOTIFICATION_ALARM` | `de.jeanlucmakiola.agendula.provider.NOTIFICATION_ALARM` | +| Resource prefix | `opentasks_*` | `agendula_*` | +| R class | `org.dmfs.tasks.provider.R` | `de.jeanlucmakiola.agendula.provider.R` | + +### What was deliberately *not* renamed + +- **Java package names** stay `org.dmfs.provider.tasks` / `org.dmfs.tasks.contract`. + They are not a registered namespace — two apps may share them freely — and + keeping them means the diff against upstream stays legible. Only the AGP + `namespace` (which decides where `R` lands) is ours. +- **`TaskContract.LOCAL_ACCOUNT_TYPE`** stays `"org.dmfs.account.LOCAL"`. It is a + value stored in the database and recognised by dmfs-contract providers + generally, so the *same* app code has to write it whether it is talking to our + provider or, in External mode, to OpenTasks. Renaming it would fork the write + path in two for no gain. +- **`ACTION_BROADCAST_TASK_DUE` / `…_TASK_STARTING` / `ACTION_DATABASE_INITIALIZED`.** + Every send site calls `setPackage()` on its own package first, so these never + cross app boundaries and cannot collide with an installed OpenTasks. + +## Changes to upstream source + +Four files under `src/main/java`, one under `src/test/java`. Each edit is marked +with an `AGENDULA CHANGE` comment at the site, so this list and the code cannot +drift apart. Keep that convention. + +### Behavioural + +1. **`Utils.cleanUpLists` — only prune account types we authenticate ourselves.** + *The one change here that is about correctness rather than mechanics.* + + Upstream holds `GET_ACCOUNTS` and enumerates every account on the device. We + dropped that permission (below), so `AccountManager` only ever reports + accounts of our own type. Upstream's cleanup deletes any task list whose + account is absent from that array — and an account we *cannot see* is + indistinguishable from one that has been *removed*. Left alone, the provider + would quietly delete synced lists. Not "sync stops": data disappears, with no + error anywhere. + + Now a list is only prunable when its account type belongs to an authenticator + in **this package**. Agendula ships no authenticator yet, so the set is empty + and nothing is ever pruned; our sync adapter's type will join it on its own + when it lands, no edit needed here. Local lists were already exempt upstream. + +2. **`TaskProvider.insert` — the same restriction for the stale-list signal.** + Upstream flags "list with unknown account" and broadcasts about it. With the + account cache holding only our own accounts, that fired on every insert into + any externally-synced list. Nothing listens today; the point is that whatever + listens tomorrow gets a signal that means something. + +3. **`TaskProviderBroadcastReceiver.onReceive` — fall-through written out.** + Upstream's `switch` has no `break` in any branch, so `TIMEZONE_CHANGED` runs + all three content operations and `NOTIFICATION_ALARM` runs the last two. The + comment on the first branch ("don't trigger the notifications update yet") + describes breaks that were never written, so the code and the stated intent + contradict each other. + + **Observed behaviour is preserved exactly**, just spelled out with `if`s + rather than reached by accident. A vendored fork is the wrong place to guess + at intent. ⚠️ **Open:** which of the two is the bug wants a device with a task + due across a timezone change to settle. + +4. **`TaskProviderBroadcastReceiver.planNotificationUpdate` — inexact-alarm fallback.** + `setExact` throws `SecurityException` on API 31–32 when the user revokes + `SCHEDULE_EXACT_ALARM` — inside a receiver handling a system broadcast, so the + app would die on every timezone change. Falls back to `set` when exact alarms + aren't permitted. This alarm drives only the provider's own bookkeeping; + Agendula's user-visible reminders come from `ReminderScheduler`, which asks + for the permission properly. + +### Required by modern Android (would not build or would crash otherwise) + +5. **`PendingIntent.FLAG_IMMUTABLE`** added in `planNotificationUpdate`. Mandatory + since Android 12; throws `IllegalArgumentException` without it at targetSdk ≥ 31. + Upstream targets 29. Nothing mutates the intent later, so immutable is also + correct on the merits. +6. **`android:exported`** stated explicitly on the receiver. AGP hard-errors on an + intent-filtered component without it at targetSdk ≥ 31. +7. **`package=` attribute** removed from the manifest; AGP 8+ takes it from the + `namespace` in the build file. +8. **``** removed + along with the androidTest sources it existed for (below). + +### Permissions + +9. **`android.permission.GET_ACCOUNTS` dropped.** We only ever need to see + accounts of our own type, and since API 26 an authenticator makes those + visible to its own package with no grant at all. Safe **only** in combination + with change 1 — the two must be read together. + +### Build and test + +10. **`build.gradle` → `build.gradle.kts`**, on the root version catalog. minSdk + 21 → 29 (matching `:app`; the merger rejects a lower floor), Java 8 → 17. +11. **Test stack modernised**, sources otherwise untouched: JUnit 4.12 → 4.13.2, + Robolectric 3.5.1 → 4.16, Mockito 2.27 → 5.20, Hamcrest 1.3 → 3.0. + `org.dmfs:jems`, `rfc5545-datetime` and `lib-recur` stay on the versions + upstream pinned — all three resolve from Maven Central, so no new repository + was added (`settings.gradle.kts` is still `google()` + `mavenCentral()` under + `FAIL_ON_PROJECT_REPOS`). +12. **`ZippedTest.testAbsent`** — diamond `new Zipped<>` given an explicit type + argument. `absent()` pins no type, and javac 17 will not infer what javac 8 + did. The assertion is unchanged. +13. **`src/test/resources/robolectric.properties` added** (`sdk=34`, + `conscryptMode=OFF`). A library module has no `targetSdk` for Robolectric to + read, and Robolectric installs Conscrypt unconditionally, whose uber jar has + no `linux-aarch_64` native — so without this the suite fails at setup on ARM64 + machines while passing on x86_64 CI. See the file for the reasoning. +14. **`src/androidTest` dropped entirely.** It depends on `contentpal` / + `contenttestpal`, which are JitPack-only; adding JitPack would widen the + dependency trust surface for test-only code. ⚠️ This is the one place + vendoring lost coverage — those were the provider's *integration* tests + (recurrence, reparenting, instances, observers). The 51 JVM tests in + `src/test` all pass and are retained. +15. **`agendula_provider_changed_receivers` emptied.** Upstream notifies + `org.andstatus.todoagenda`, which listens for changes to the *dmfs* authority + and has never heard of ours. Anything re-added here also needs a `` + entry in the app manifest or package-visibility rules drop the broadcast. +16. **Translated `agendula_provider_label` overrides removed** (the other + translated strings are kept as upstream shipped them). The base label became + "Agendula tasks" so it is distinguishable from OpenTasks' own "Tasks" entry in + the system permission dialog; the inherited translations still said plain + "Tasks" in their language, which would have contradicted it. + +## Resyncing from upstream + +Unlikely to ever be worth it — upstream 1.4.2 is from 2021 — but if it is: the +`AGENDULA CHANGE` markers are the complete list of what to reapply, `git log` on +this directory is the audit trail, and the 51 JVM tests are the safety net. +Re-read change 1 before touching anything account-related. + +## Known-unverified + +Everything here is verified by the JVM test suite and a clean build. What is +**not** yet verified on a device with real data: + +- The local-list path with **no account present at all** — the entirety of Local + mode. `cleanUpLists` exempts local lists explicitly and change 1 makes the + prunable set empty, so it should hold by construction; it is covered by + `ProviderAccountCleanupTest`, but that is Robolectric, not a device. +- The timezone-change behaviour in change 3. +- Any interaction with an external sync engine writing into our authority + (nothing does yet — that is the DAVx5 ask, step 4 of the sequencing). diff --git a/provider/build.gradle.kts b/provider/build.gradle.kts new file mode 100644 index 0000000..3206e9c --- /dev/null +++ b/provider/build.gradle.kts @@ -0,0 +1,66 @@ +// Agendula's own task store: the dmfs task provider (Apache-2.0), vendored. +// +// This module is a fork, not a dependency. What we changed and why is recorded +// in PROVENANCE.md; the short version is that the authority and the permission +// names had to become ours, and the permission names are hardcoded in the +// upstream AAR's manifest, so no prebuilt artifact could have been used. +// +// It stays Java, on upstream's `org.dmfs.*` package names, formatted the way +// upstream formats it. That is deliberate: every deviation from upstream is a +// line we have to re-reason about if we ever resync, so the diff is kept +// legible rather than idiomatic. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.agendula.provider" + compileSdk = 37 + + defaultConfig { + // Matches :app. Upstream ships minSdk 21 / targetSdk 29; targetSdk is set + // by the application module anyway, but the SDK floor here has to agree + // with :app's or the manifest merger rejects it. + minSdk = 29 + + consumerProguardFiles("proguard-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + // The provider reads its own authority out of resources, so it needs R. + // It has no BuildConfig use at all. + buildConfig = false + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + lint { + // Upstream's translations are inherited as-is and are partial — a missing + // string falls back to the English base at runtime. Same call as :app. + informational += listOf("MissingTranslation") + } +} + +dependencies { + implementation(libs.dmfs.jems) + implementation(libs.dmfs.rfc5545.datetime) + implementation(libs.dmfs.lib.recur) + + // Upstream's own JVM test suite, on current versions of its stack. Note this + // module is JUnit 4 while :app is JUnit 5 — deliberately, see the version + // catalog. Do not add `useJUnitPlatform()` here. + testImplementation(libs.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.hamcrest) + testImplementation(libs.mockito.core) + testImplementation(libs.dmfs.jems.testing) +} diff --git a/provider/proguard-rules.pro b/provider/proguard-rules.pro new file mode 100644 index 0000000..6a525b0 --- /dev/null +++ b/provider/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/marten/.local/Android/Sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/provider/src/main/AndroidManifest.xml b/provider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8cd8305 --- /dev/null +++ b/provider/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java b/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java new file mode 100644 index 0000000..58e3064 --- /dev/null +++ b/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java @@ -0,0 +1,168 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.ngrams; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + + +/** + * Generator for N-grams from a given String. + * + * @author Marten Gajda + */ +public final class NGramGenerator +{ + /** + * A {@link Pattern} that matches anything that doesn't belong to a word or number. + */ + private final static Pattern SEPARATOR_PATTERN = Pattern.compile("[^\\p{L}\\p{M}\\d]+"); + + /** + * A {@link Pattern} that matches anything that doesn't belong to a word. + */ + private final static Pattern SEPARATOR_PATTERN_NO_NUMBERS = Pattern.compile("[^\\p{L}\\p{M}]+"); + + private final int mN; + private final int mMinWordLen; + private boolean mAllLowercase = true; + private boolean mReturnNumbers = true; + private boolean mAddSpaceInFront = false; + private Locale mLocale = Locale.getDefault(); + + + public NGramGenerator(int n) + { + this(n, 1); + } + + + public NGramGenerator(int n, int minWordLen) + { + mN = n; + mMinWordLen = minWordLen; + } + + + /** + * Set whether to convert all words to lower-case first. + * + * @param lowercase + * true to convert the test to lower case first. + * + * @return This instance. + */ + public NGramGenerator setAllLowercase(boolean lowercase) + { + mAllLowercase = lowercase; + return this; + } + + + /** + * Set whether to index the beginning of a word with a space in front. This slightly raises the weight of word beginnings when searching. + * + * @param addSpace + * true to add a space in front of each word, false otherwise. + * + * @return This instance. + */ + public NGramGenerator setAddSpaceInFront(boolean addSpace) + { + mAddSpaceInFront = addSpace; + return this; + } + + + /** + * Sets the {@link Locale} to use when converting the input string to lower case. This has no effect when {@link #setAllLowercase(boolean)} is called with + * false. + * + * @param locale + * The {@link Locale} to user for the conversion to lower case. + * + * @return This instance. + */ + public NGramGenerator setLocale(Locale locale) + { + mLocale = locale; + return this; + } + + + /** + * Get all N-grams contained in the given String. + * + * @param data + * The String to analyze. + * + * @return The {@link Set} containing the N-grams. + */ + public Set getNgrams(String data) + { + if (data == null) + { + return Collections.emptySet(); + } + + if (mAllLowercase) + { + data = data.toLowerCase(mLocale); + } + + String[] words = mReturnNumbers ? SEPARATOR_PATTERN.split(data) : SEPARATOR_PATTERN_NO_NUMBERS.split(data); + + Set set = new HashSet(128); + + for (String word : words) + { + getNgrams(word, set); + } + + return set; + } + + + private void getNgrams(String word, Set ngrams) + { + final int len = word.length(); + + if (len < mMinWordLen) + { + return; + } + + final int n = mN; + final int last = Math.max(1, len - n + 1); + + for (int i = 0; i < last; ++i) + { + ngrams.add(word.substring(i, Math.min(i + n, len))); + } + + if (mAddSpaceInFront) + { + /* + * Add another String with a space and the first n-1 characters of the word. + */ + ngrams.add(" " + word.substring(0, Math.min(len, n - 1))); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java b/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java new file mode 100644 index 0000000..369cc57 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.content.Context; + +import de.jeanlucmakiola.agendula.provider.R; + + +/** + * Access for the authority name of the tasks content provider. + * + * @author Gabor Keszthelyi + */ +// TODO Figure out better design or at least rename to TaskAuthority.get(context) (results in changes in many files) +public final class AuthorityUtil +{ + private static String sCachedValue; + + + public static String taskAuthority(Context context) + { + if (sCachedValue == null) + { + sCachedValue = context.getString(R.string.agendula_tasks_authority); + } + return sCachedValue; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java b/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java new file mode 100644 index 0000000..622533c --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java @@ -0,0 +1,419 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Handler; +import android.util.Log; + +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.tasks.Instantiating; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.TimeZone; + + +public enum ContentOperation +{ + /** + * When the local timezone has been changed we need to update the due and start sorting values. This handler will take care of running the appropriate + * update. In addition it fires an operation to update all notifications. + */ + UPDATE_TIMEZONE(new OperationHandler() + { + @Override + public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) + { + long start = System.currentTimeMillis(); + + // request an update of all instance values + ContentValues vals = new ContentValues(1); + Instantiating.addUpdateRequest(vals); + + // execute update that triggers a recalculation of all due and start sorting values + int count = context.getContentResolver().update( + TaskContract.Tasks.getContentUri(uri.getAuthority()).buildUpon().appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true").build(), + vals, null, null); + + Log.i("TaskProvider", "time to update " + count + " tasks: " + (System.currentTimeMillis() - start) + " ms"); + + // now update alarms as well + UPDATE_NOTIFICATION_ALARM.fire(context, null); + } + }), + + /** + * Takes care of everything we need to send task start and task due broadcasts. + */ + POST_NOTIFICATIONS(new OperationHandler() + { + + @Override + public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) + { + TimeZone localTimeZone = TimeZone.getDefault(); + + // the date-time of when the last notification was shown + DateTime lastAlarm = getLastAlarmTimestamp(context); + // the current time, we show all notifications between and now + DateTime now = DateTime.nowAndHere(); + + String lastAlarmString = Long.toString(lastAlarm.getInstance()); + String nowString = Long.toString(now.getInstance()); + + // load all tasks that have started or became due since the last time we've shown a notification. + Cursor instancesCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, "((" + TaskContract.Instances.INSTANCE_DUE_SORTING + ">? and " + + TaskContract.Instances.INSTANCE_DUE_SORTING + "<=?) or (" + TaskContract.Instances.INSTANCE_START_SORTING + ">? and " + + TaskContract.Instances.INSTANCE_START_SORTING + "<=?)) and " + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { + lastAlarmString, nowString, lastAlarmString, nowString }, null, null, null); + + try + { + while (instancesCursor.moveToNext()) + { + InstanceAdapter task = new CursorContentValuesInstanceAdapter(InstanceAdapter._ID.getFrom(instancesCursor), instancesCursor, null); + + DateTime instanceDue = task.valueOf(InstanceAdapter.INSTANCE_DUE); + if (instanceDue != null && !instanceDue.isFloating()) + { + // make sure we compare instances in local time + instanceDue = instanceDue.shiftTimeZone(localTimeZone); + } + + DateTime instanceStart = task.valueOf(InstanceAdapter.INSTANCE_START); + if (instanceStart != null && !instanceStart.isFloating()) + { + // make sure we compare instances in local time + instanceStart = instanceStart.shiftTimeZone(localTimeZone); + } + + if (instanceDue != null && lastAlarm.getInstance() < instanceDue.getInstance() && instanceDue.getInstance() <= now.getInstance()) + { + // this task became due since the last alarm, send a due broadcast + sendBroadcast(context, TaskContract.ACTION_BROADCAST_TASK_DUE, task.uri(uri.getAuthority())); + } + else if (instanceStart != null && lastAlarm.getInstance() < instanceStart.getInstance() && instanceStart.getInstance() <= now.getInstance()) + { + // this task has started since the last alarm, send a start broadcast + sendBroadcast(context, TaskContract.ACTION_BROADCAST_TASK_STARTING, task.uri(uri.getAuthority())); + } + } + } + finally + { + instancesCursor.close(); + } + + // all notifications up to now have been triggered + saveLastAlarmTime(context, now); + + // set the alarm for the next notification + UPDATE_NOTIFICATION_ALARM.fire(context, null); + } + + + @SuppressLint("NewApi") + private void saveLastAlarmTime(Context context, DateTime time) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Editor editor = prefs.edit(); + editor.putLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, time.getTimestamp()); + editor.apply(); + } + + + private DateTime getLastAlarmTimestamp(Context context) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return new DateTime(TimeZone.getDefault(), prefs.getLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, System.currentTimeMillis())); + } + + + /** + * Sends a notification broadcast for a task instance that has started or became due. + * + * @param context + * A {@link Context}. + * @param action + * The broadcast action. + * @param uri + * The task uri. + */ + private void sendBroadcast(Context context, String action, Uri uri) + { + Intent intent = new Intent(action); + intent.setData(uri); + // only notify our own package + intent.setPackage(context.getPackageName()); + context.sendBroadcast(intent); + } + }), + + /** + * Determines the date-time of when the next task becomes due or starts (whatever happens first) and sets an alarm to trigger a notification. + */ + UPDATE_NOTIFICATION_ALARM(new OperationHandler() + { + + @Override + public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) + { + TimeZone localTimeZone = TimeZone.getDefault(); + DateTime lastAlarm = getLastAlarmTimestamp(context); + DateTime now = DateTime.nowAndHere(); + + if (now.before(lastAlarm)) + { + // time went backwards, set last alarm time to now + lastAlarm = now; + saveLastAlarmTime(context, now); + } + + String lastAlarmString = Long.toString(lastAlarm.getInstance()); + + DateTime nextAlarm = null; + + // find the next task that starts + Cursor nextInstanceStartCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, TaskContract.Instances.INSTANCE_START_SORTING + ">? and " + + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { lastAlarmString }, null, null, + TaskContract.Instances.INSTANCE_START_SORTING, "1"); + + try + { + if (nextInstanceStartCursor.moveToNext()) + { + TaskAdapter task = new CursorContentValuesTaskAdapter(TaskAdapter.INSTANCE_TASK_ID.getFrom(nextInstanceStartCursor), + nextInstanceStartCursor, null); + nextAlarm = task.valueOf(TaskAdapter.INSTANCE_START); + if (!nextAlarm.isFloating()) + { + nextAlarm = nextAlarm.shiftTimeZone(localTimeZone); + } + } + } + finally + { + nextInstanceStartCursor.close(); + } + + // find the next task that's due + Cursor nextInstanceDueCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, TaskContract.Instances.INSTANCE_DUE_SORTING + ">? and " + + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { lastAlarmString }, null, null, + TaskContract.Instances.INSTANCE_DUE_SORTING, "1"); + + try + { + if (nextInstanceDueCursor.moveToNext()) + { + TaskAdapter task = new CursorContentValuesTaskAdapter(TaskAdapter.INSTANCE_TASK_ID.getFrom(nextInstanceDueCursor), nextInstanceDueCursor, + null); + DateTime nextDue = task.valueOf(TaskAdapter.INSTANCE_DUE); + if (!nextDue.isFloating()) + { + nextDue = nextDue.shiftTimeZone(localTimeZone); + } + + if (nextAlarm == null || nextAlarm.getInstance() > nextDue.getInstance()) + { + nextAlarm = nextDue; + } + } + } + finally + { + nextInstanceDueCursor.close(); + } + + if (nextAlarm != null) + { + TaskProviderBroadcastReceiver.planNotificationUpdate(context, nextAlarm); + } + else + { + saveLastAlarmTime(context, now); + } + } + + + @SuppressLint("NewApi") + private void saveLastAlarmTime(Context context, DateTime time) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Editor editor = prefs.edit(); + editor.putLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, time.getTimestamp()); + editor.apply(); + } + + + private DateTime getLastAlarmTimestamp(Context context) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return new DateTime(TimeZone.getDefault(), prefs.getLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, System.currentTimeMillis())); + } + + }); + + /** + * A lock object to serialize the execution of all incoming {@link ContentOperation}. + */ + private final static Object mLock = new Object(); + + /** + * The base path of the Uri to trigger content operations. + */ + private final static String BASE_PATH = "content_operation"; + + /** + * The {@link OperationHandler} that handles this {@link ContentOperation}. + */ + private final OperationHandler mHandler; + + private static final String PREFS_NAME = "org.dmfs.provider.tasks"; + private static final String PREFS_KEY_LAST_ALARM_TIMESTAMP = "org.dmfs.provider.tasks.prefs.LAST_ALARM_TIMESTAMP"; + + + ContentOperation(OperationHandler handler) + { + mHandler = handler; + } + + + /** + * Execute this {@link ContentOperation} with the given values. + * + * @param context + * A {@link Context}. + * @param values + * Optional {@link ContentValues}, may be null. + */ + public void fire(Context context, ContentValues values) + { + context.getContentResolver().update(uri(AuthorityUtil.taskAuthority(context)), values == null ? new ContentValues() : values, null, null); + } + + + /** + * Run the operation on the given handler. + * + * @param context + * A {@link Context}. + * @param handler + * A {@link Handler} to run the operation on. + * @param uri + * The {@link Uri} that triggered this operation. + * @param db + * The database. + * @param values + * The {@link ContentValues} that were supplied. + */ + void run(final Context context, Handler handler, final Uri uri, final SQLiteDatabase db, final ContentValues values) + { + handler.post(new Runnable() + { + @Override + public void run() + { + synchronized (mLock) + { + mHandler.handleOperation(context, uri, db, values); + } + } + }); + } + + + /** + * Returns the {@link Uri} that triggers this {@link ContentOperation}. + * + * @param authority + * The authority of this provide. + * + * @return A {@link Uri}. + */ + private Uri uri(String authority) + { + return new Uri.Builder().scheme("content").authority(authority).path(BASE_PATH).appendPath(this.toString()).build(); + } + + + /** + * Register the operations with the given {@link UriMatcher}. + * + * @param uriMatcher + * The {@link UriMatcher}. + * @param authority + * The authority of this TaskProvider. + * @param firstID + * Teh first Id to use for our Uris. + */ + public static void register(UriMatcher uriMatcher, String authority, int firstID) + { + for (ContentOperation op : values()) + { + Uri uri = op.uri(authority); + uriMatcher.addURI(authority, uri.getPath().substring(1) /* remove leading slash */, firstID + op.ordinal()); + } + } + + + /** + * Return a {@link ContentOperation} that belongs to the given id. + * + * @param id + * The id or the {@link ContentOperation}. + * @param firstId + * The first ID to use for Uris. + * + * @return The respective {@link ContentOperation} or null if none was found. + */ + public static ContentOperation get(int id, int firstId) + { + if (id < firstId) + { + return null; + } + + if (id - firstId >= values().length) + { + return null; + } + + return values()[id - firstId]; + } + + + public interface OperationHandler + { + void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java b/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java new file mode 100644 index 0000000..1093a4f --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java @@ -0,0 +1,630 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import org.dmfs.jems.iterable.decorators.Chunked; +import org.dmfs.ngrams.NGramGenerator; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.TaskColumns; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +/** + * Supports the {@link TaskDatabaseHelper} in the matter of full-text-search. + * + * @author Tobias Reinsch + * @author Marten Gajda + */ +public class FTSDatabaseHelper +{ + /** + * We search the ngram table in chunks of 500. This should be good enough for an average task but still well below + * the SQLITE expression length limit and the variable count limit. + */ + private final static int NGRAM_SEARCH_CHUNK_SIZE = 500; + + private final static float SEARCH_RESULTS_MIN_SCORE = 0.33f; + + /** + * A Generator for 3-grams. + */ + private final static NGramGenerator TRIGRAM_GENERATOR = new NGramGenerator(3, 1).setAddSpaceInFront(true); + + /** + * A Generator for 4-grams. + */ + private final static NGramGenerator TETRAGRAM_GENERATOR = new NGramGenerator(4, 3 /* shorter words are fully covered by trigrams */).setAddSpaceInFront( + true); + private static final String PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s = ?", FTSContentColumns.TASK_ID, FTSContentColumns.TYPE, + FTSContentColumns.PROPERTY_ID); + private static final String NON_PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s is null", FTSContentColumns.TASK_ID, + FTSContentColumns.TYPE, + FTSContentColumns.PROPERTY_ID); + private static final String[] NGRAM_SYNC_COLUMNS = { "_rowid_", FTSContentColumns.NGRAM_ID }; + + + /** + * Search content columns. Defines all the columns for the full text search + * + * @author Tobias Reinsch + */ + public interface FTSContentColumns + { + /** + * The row id of the belonging task. + */ + String TASK_ID = "fts_task_id"; + + /** + * The the property id of the searchable entry or null if the entry is not related to a property. + */ + String PROPERTY_ID = "fts_property_id"; + + /** + * The the type of the searchable entry + */ + String TYPE = "fts_type"; + + /** + * An n-gram for a task. + */ + String NGRAM_ID = "fts_ngram_id"; + + } + + + /** + * The columns of the N-gram table for the FTS search + * + * @author Tobias Reinsch + */ + public interface NGramColumns + { + /** + * The row id of the N-gram. + */ + String NGRAM_ID = "ngram_id"; + + /** + * The content of the N-gram + */ + String TEXT = "ngram_text"; + + } + + + public static final String FTS_CONTENT_TABLE = "FTS_Content"; + public static final String FTS_NGRAM_TABLE = "FTS_Ngram"; + public static final String FTS_TASK_VIEW = "FTS_Task_View"; + public static final String FTS_TASK_PROPERTY_VIEW = "FTS_Task_Property_View"; + + /** + * SQL command to create the table for full text search and contains relationships between ngrams and tasks + */ + private final static String SQL_CREATE_SEARCH_CONTENT_TABLE = "CREATE TABLE " + FTS_CONTENT_TABLE + "( " + FTSContentColumns.TASK_ID + " Integer, " + + FTSContentColumns.NGRAM_ID + " Integer, " + FTSContentColumns.PROPERTY_ID + " Integer, " + FTSContentColumns.TYPE + " Integer, " + "FOREIGN KEY(" + + FTSContentColumns.TASK_ID + ") REFERENCES " + Tables.TASKS + "(" + TaskColumns._ID + ")," + "FOREIGN KEY(" + FTSContentColumns.TASK_ID + + ") REFERENCES " + Tables.TASKS + "(" + TaskColumns._ID + ") UNIQUE (" + FTSContentColumns.TASK_ID + ", " + FTSContentColumns.TYPE + ", " + + FTSContentColumns.PROPERTY_ID + ") ON CONFLICT IGNORE )"; + + /** + * SQL command to create the table that stores the NGRAMS + */ + private final static String SQL_CREATE_NGRAM_TABLE = "CREATE TABLE " + FTS_NGRAM_TABLE + "( " + NGramColumns.NGRAM_ID + + " Integer PRIMARY KEY AUTOINCREMENT, " + NGramColumns.TEXT + " Text)"; + + // FIXME: at present the minimum score is hard coded can we leave that decision to the caller? + private final static String SQL_RAW_QUERY_SEARCH_TASK = "SELECT %s " + ", (1.0*count(DISTINCT " + NGramColumns.NGRAM_ID + ")/?) as " + TaskContract.Tasks.SCORE + " from " + + FTS_NGRAM_TABLE + " join " + FTS_CONTENT_TABLE + " on (" + FTS_NGRAM_TABLE + "." + NGramColumns.NGRAM_ID + "=" + FTS_CONTENT_TABLE + "." + + FTSContentColumns.NGRAM_ID + ") join " + Tables.INSTANCE_VIEW + " on (" + Tables.INSTANCE_VIEW + "." + TaskContract.Instances.TASK_ID + " = " + FTS_CONTENT_TABLE + "." + + FTSContentColumns.TASK_ID + ") where %s group by " + TaskContract.Instances.TASK_ID + " having " + TaskContract.Tasks.SCORE + " >= " + SEARCH_RESULTS_MIN_SCORE + + " and " + Tasks.VISIBLE + " = 1 order by %s;"; + + private final static String SQL_RAW_QUERY_SEARCH_TASK_DEFAULT_PROJECTION = Tables.INSTANCE_VIEW + ".* ," + FTS_NGRAM_TABLE + "." + NGramColumns.TEXT; + + private final static String SQL_CREATE_SEARCH_TASK_DELETE_TRIGGER = "CREATE TRIGGER search_task_delete_trigger AFTER DELETE ON " + Tables.TASKS + " BEGIN " + + " DELETE FROM " + FTS_CONTENT_TABLE + " WHERE " + FTSContentColumns.TASK_ID + " = old." + Tasks._ID + "; END"; + + private final static String SQL_CREATE_SEARCH_TASK_DELETE_PROPERTY_TRIGGER = "CREATE TRIGGER search_task_delete_property_trigger AFTER DELETE ON " + + Tables.PROPERTIES + " BEGIN " + " DELETE FROM " + FTS_CONTENT_TABLE + " WHERE " + FTSContentColumns.TASK_ID + " = old." + Properties.TASK_ID + + " AND " + FTSContentColumns.PROPERTY_ID + " = old." + Properties.PROPERTY_ID + "; END"; + + + /** + * The different types of searchable entries for tasks linked to the TYPE column. + * + * @author Tobias Reinsch + * @author Marten Gajda + */ + public interface SearchableTypes + { + /** + * This is an entry for the title of a task. + */ + int TITLE = 1; + + /** + * This is an entry for the description of a task. + */ + int DESCRIPTION = 2; + + /** + * This is an entry for the location of a task. + */ + int LOCATION = 3; + + /** + * This is an entry for a property of a task. + */ + int PROPERTY = 4; + + } + + + public static void onCreate(SQLiteDatabase db) + { + initializeFTS(db); + } + + + public static void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) + { + if (oldVersion < 8) + { + initializeFTS(db); + initializeFTSContent(db); + } + if (oldVersion < 16) + { + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.TYPE, FTSContentColumns.TASK_ID, + FTSContentColumns.PROPERTY_ID)); + } + } + + + /** + * Creates the tables and triggers used in FTS. + * + * @param db + * The {@link SQLiteDatabase}. + */ + private static void initializeFTS(SQLiteDatabase db) + { + db.execSQL(SQL_CREATE_SEARCH_CONTENT_TABLE); + db.execSQL(SQL_CREATE_NGRAM_TABLE); + db.execSQL(SQL_CREATE_SEARCH_TASK_DELETE_TRIGGER); + db.execSQL(SQL_CREATE_SEARCH_TASK_DELETE_PROPERTY_TRIGGER); + + // create indices + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_NGRAM_TABLE, true, NGramColumns.TEXT)); + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, false, FTSContentColumns.NGRAM_ID)); + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, false, FTSContentColumns.TASK_ID)); + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.PROPERTY_ID, FTSContentColumns.TASK_ID, + FTSContentColumns.NGRAM_ID)); + + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.TYPE, FTSContentColumns.TASK_ID, + FTSContentColumns.PROPERTY_ID)); + + } + + + /** + * Creates the FTS entries for the existing tasks. + * + * @param db + * The writable {@link SQLiteDatabase}. + */ + private static void initializeFTSContent(SQLiteDatabase db) + { + String[] task_projection = new String[] { Tasks._ID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION }; + Cursor c = db.query(Tables.TASKS_PROPERTY_VIEW, task_projection, null, null, null, null, null); + while (c.moveToNext()) + { + insertTaskFTSEntries(db, c.getLong(0), c.getString(1), c.getString(2), c.getString(3)); + } + c.close(); + } + + + /** + * Inserts the searchable texts of the task in the database. + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param taskId + * The row id of the task. + * @param title + * The title of the task. + * @param description + * The description of the task. + */ + private static void insertTaskFTSEntries(SQLiteDatabase db, long taskId, String title, String description, String location) + { + // title + if (title != null && title.length() > 0) + { + updateEntry(db, taskId, -1, SearchableTypes.TITLE, title); + } + + // location + if (location != null && location.length() > 0) + { + updateEntry(db, taskId, -1, SearchableTypes.LOCATION, location); + } + + // description + if (description != null && description.length() > 0) + { + updateEntry(db, taskId, -1, SearchableTypes.DESCRIPTION, description); + } + + } + + + /** + * Updates the existing searchables entries for the task. + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param task + * The {@link TaskAdapter} containing the new values. + */ + public static void updateTaskFTSEntries(SQLiteDatabase db, TaskAdapter task) + { + // title + if (task.isUpdated(TaskAdapter.TITLE)) + { + updateEntry(db, task.id(), -1, SearchableTypes.TITLE, task.valueOf(TaskAdapter.TITLE)); + } + + // location + if (task.isUpdated(TaskAdapter.LOCATION)) + { + updateEntry(db, task.id(), -1, SearchableTypes.LOCATION, task.valueOf(TaskAdapter.LOCATION)); + } + + // description + if (task.isUpdated(TaskAdapter.DESCRIPTION)) + { + updateEntry(db, task.id(), -1, SearchableTypes.DESCRIPTION, task.valueOf(TaskAdapter.DESCRIPTION)); + } + + } + + + /** + * Updates or creates the searchable entries for a property. Passing null as searchable text will remove the entry. + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param taskId + * the row id of the task this property belongs to. + * @param propertyId + * the id of the property + * @param searchableText + * the searchable text value of the property + */ + public static void updatePropertyFTSEntry(SQLiteDatabase db, long taskId, long propertyId, String searchableText) + { + updateEntry(db, taskId, propertyId, SearchableTypes.PROPERTY, searchableText); + } + + + /** + * Returns the IDs of each of the provided ngrams, creating them in th database if necessary. + * + * @param db + * A writable {@link SQLiteDatabase}. + * @param ngrams + * The NGrams. + * + * @return The ids of the ngrams in the given set. + */ + private static Set ngramIds(SQLiteDatabase db, Set ngrams) + { + if (ngrams.size() == 0) + { + return Collections.emptySet(); + } + + Set missingNgrams = new HashSet<>(ngrams); + Set ngramIds = new HashSet<>(ngrams.size() * 2); + + for (Iterable chunk : new Chunked<>(NGRAM_SEARCH_CHUNK_SIZE, ngrams)) + { + // build selection and arguments for each chunk + // we can't do this in a single query because the length of sql statement and number of arguments is limited. + + StringBuilder selection = new StringBuilder(NGramColumns.TEXT); + selection.append(" in ("); + boolean first = true; + List arguments = new ArrayList<>(NGRAM_SEARCH_CHUNK_SIZE); + for (String ngram : chunk) + { + if (first) + { + first = false; + } + else + { + selection.append(","); + } + selection.append("?"); + arguments.add(ngram); + } + selection.append(" )"); + + try (Cursor c = db.query(FTS_NGRAM_TABLE, new String[] { NGramColumns.NGRAM_ID, NGramColumns.TEXT }, selection.toString(), + arguments.toArray(new String[0]), null, null, null)) + { + while (c.moveToNext()) + { + // remove the ngrams we already have in the table + missingNgrams.remove(c.getString(1)); + // remember its id + ngramIds.add(c.getLong(0)); + } + } + } + + ContentValues values = new ContentValues(1); + + // now insert the missing ngrams and store their ids + for (String ngram : missingNgrams) + { + values.put(NGramColumns.TEXT, ngram); + ngramIds.add(db.insert(FTS_NGRAM_TABLE, null, values)); + } + return ngramIds; + + } + + + private static void updateEntry(SQLiteDatabase db, long taskId, long propertyId, int type, String searchableText) + { + // generate nGrams + Set propertyNgrams = TRIGRAM_GENERATOR.getNgrams(searchableText); + propertyNgrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchableText)); + + // get an ID for each of the Ngrams. + Set ngramIds = ngramIds(db, propertyNgrams); + + // unlink unused ngrams from the task and get the missing ones we have to link to the tak + Set missing = syncNgrams(db, taskId, propertyId, type, ngramIds); + + // insert ngram relations for all new ngrams + addNgrams(db, missing, taskId, propertyId, type); + } + + + /** + * Inserts NGrams relations for a task entry. + * + * @param db + * A writable {@link SQLiteDatabase}. + * @param ngramIds + * The set of NGram ids. + * @param taskId + * The row id of the task. + * @param propertyId + * The row id of the property. + */ + private static void addNgrams(SQLiteDatabase db, Set ngramIds, long taskId, Long propertyId, int contentType) + { + ContentValues values = new ContentValues(4); + for (Long ngramId : ngramIds) + { + values.put(FTSContentColumns.TASK_ID, taskId); + values.put(FTSContentColumns.NGRAM_ID, ngramId); + values.put(FTSContentColumns.TYPE, contentType); + if (contentType == SearchableTypes.PROPERTY) + { + values.put(FTSContentColumns.PROPERTY_ID, propertyId); + } + else + { + values.putNull(FTSContentColumns.PROPERTY_ID); + } + db.insert(FTS_CONTENT_TABLE, null, values); + } + + } + + + /** + * Synchronizes the NGram relations of a task + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param taskId + * The task row id. + * @param propertyId + * The property row id, ignored if contentType is not {@link SearchableTypes#PROPERTY}. + * @param contentType + * The {@link SearchableTypes} type. + * @param ngramsIds + * The set of ngrams ids which should be linked to the task + * + * @return The number of deleted relations. + */ + private static Set syncNgrams(SQLiteDatabase db, long taskId, long propertyId, int contentType, Set ngramsIds) + { + String selection; + String[] selectionArgs; + if (SearchableTypes.PROPERTY == contentType) + { + selection = PROPERTY_NGRAM_SELECTION; + selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType), String.valueOf(propertyId) }; + } + else + { + selection = NON_PROPERTY_NGRAM_SELECTION; + selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType) }; + } + + // In order to sync the ngrams, we go over each existing ngram and delete ngram relations not in the set of new ngrams + // Then we return the set of ngrams we didn't find + Set missing = new HashSet<>(ngramsIds); + try (Cursor c = db.query(FTS_CONTENT_TABLE, NGRAM_SYNC_COLUMNS, selection, selectionArgs, null, null, null)) + { + while (c.moveToNext()) + { + Long ngramId = c.getLong(1); + if (!ngramsIds.contains(ngramId)) + { + db.delete(FTS_CONTENT_TABLE, "_rowid_ = ?", new String[] { c.getString(0) }); + } + else + { + // this ngram wasn't missing + missing.remove(ngramId); + } + } + } + return missing; + } + + + /** + * Queries the task database to get a cursor with the search results. + * + * @param db + * The {@link SQLiteDatabase}. + * @param searchString + * The search query string. + * @param projection + * The database projection for the query. + * @param selection + * The selection for the query. + * @param selectionArgs + * The arguments for the query. + * @param sortOrder + * The sorting order of the query. + * + * @return A cursor of the task database with the search result. + */ + public static Cursor getTaskSearchCursor(SQLiteDatabase db, String searchString, String[] projection, String selection, String[] selectionArgs, + String sortOrder) + { + + StringBuilder selectionBuilder = new StringBuilder(1024); + + if (!TextUtils.isEmpty(selection)) + { + selectionBuilder.append(" ("); + selectionBuilder.append(selection); + selectionBuilder.append(") AND ("); + } + else + { + selectionBuilder.append(" ("); + } + + Set ngrams = TRIGRAM_GENERATOR.getNgrams(searchString); + ngrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchString)); + + String[] queryArgs; + + if (searchString != null && searchString.length() > 1) + { + + selectionBuilder.append(NGramColumns.TEXT); + selectionBuilder.append(" in ("); + + for (int i = 0, count = ngrams.size(); i < count; ++i) + { + if (i > 0) + { + selectionBuilder.append(","); + } + selectionBuilder.append("?"); + + } + + // selection arguments + if (selectionArgs != null && selectionArgs.length > 0) + { + queryArgs = new String[selectionArgs.length + ngrams.size() + 1]; + queryArgs[0] = String.valueOf(ngrams.size()); + System.arraycopy(selectionArgs, 0, queryArgs, 1, selectionArgs.length); + String[] ngramArray = ngrams.toArray(new String[ngrams.size()]); + System.arraycopy(ngramArray, 0, queryArgs, selectionArgs.length + 1, ngramArray.length); + } + else + { + String[] temp = ngrams.toArray(new String[ngrams.size()]); + + queryArgs = new String[temp.length + 1]; + queryArgs[0] = String.valueOf(ngrams.size()); + System.arraycopy(temp, 0, queryArgs, 1, temp.length); + } + selectionBuilder.append(" ) "); + } + else + { + selectionBuilder.append(NGramColumns.TEXT); + selectionBuilder.append(" like ?"); + + // selection arguments + if (selectionArgs != null && selectionArgs.length > 0) + { + queryArgs = new String[selectionArgs.length + 2]; + queryArgs[0] = String.valueOf(ngrams.size()); + System.arraycopy(selectionArgs, 0, queryArgs, 1, selectionArgs.length); + queryArgs[queryArgs.length - 1] = " " + searchString + "%"; + } + else + { + queryArgs = new String[2]; + queryArgs[0] = String.valueOf(ngrams.size()); + queryArgs[1] = " " + searchString + "%"; + } + + } + + selectionBuilder.append(") AND "); + selectionBuilder.append(Tasks._DELETED); + selectionBuilder.append(" = 0"); + + if (sortOrder == null) + { + sortOrder = Tasks.SCORE + " desc"; + } + else + { + sortOrder = Tasks.SCORE + " desc, " + sortOrder; + } + Cursor c = db.rawQueryWithFactory(null, + String.format(SQL_RAW_QUERY_SEARCH_TASK, SQL_RAW_QUERY_SEARCH_TASK_DEFAULT_PROJECTION, selectionBuilder.toString(), sortOrder), queryArgs, + null); + return c; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java b/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java new file mode 100644 index 0000000..2d50bfd --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +/** + * @author Marten Gajda + */ +public enum ProviderOperation +{ + + /** + * Insert operations. + */ + INSERT, + + /** + * Update operations. + */ + UPDATE, + + /** + * Delete operations. + */ + DELETE +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java b/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java new file mode 100644 index 0000000..21c54ae --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package org.dmfs.provider.tasks; + +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.jems.fragile.Fragile; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.utils.Profiled; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + + +/** + * General purpose {@link ContentProvider} base class that uses SQLiteDatabase for storage. + */ +/* + * Changed by marten@dmfs.org: + * + * removed protected mDb field and replaced it by local fields. There is no reason to store the database if we get a new one for every transaction. Instead we + * also pass the database to the *InTransaction methods. + * + * update visibility of class and methods + */ +abstract class SQLiteContentProvider extends ContentProvider +{ + + interface TransactionEndTask + { + void execute(SQLiteDatabase database); + } + + + @SuppressWarnings("unused") + private static final String TAG = "SQLiteContentProvider"; + + private SQLiteOpenHelper mOpenHelper; + private final Set mChangedUris = new HashSet<>(); + + private final ThreadLocal mApplyingBatch = new ThreadLocal(); + private static final int SLEEP_AFTER_YIELD_DELAY = 4000; + + /** + * Maximum number of operations allowed in a batch between yield points. + */ + private static final int MAX_OPERATIONS_PER_YIELD_POINT = 500; + + private final Iterable mTransactionEndTasks; + + + protected SQLiteContentProvider(Iterable transactionEndTasks) + { + // append a task to set the transaction to successful + mTransactionEndTasks = new Joined<>(transactionEndTasks, new SingletonIterable<>(new SuccessfulTransactionEndTask())); + } + + + @Override + public boolean onCreate() + { + mOpenHelper = getDatabaseHelper(getContext()); + return true; + } + + + /** + * Returns a {@link SQLiteOpenHelper} that can open the database. + */ + protected abstract SQLiteOpenHelper getDatabaseHelper(Context context); + + /** + * The equivalent of the {@link #insert} method, but invoked within a transaction. + */ + public abstract Uri insertInTransaction(SQLiteDatabase db, Uri uri, ContentValues values, boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #update} method, but invoked within a transaction. + */ + public abstract int updateInTransaction(SQLiteDatabase db, Uri uri, ContentValues values, String selection, String[] selectionArgs, + boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #delete} method, but invoked within a transaction. + */ + public abstract int deleteInTransaction(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs, boolean callerIsSyncAdapter); + + + /** + * Call this to add a URI to the list of URIs to be notified when the transaction is committed. + */ + protected void postNotifyUri(Uri uri) + { + synchronized (mChangedUris) + { + mChangedUris.add(uri); + } + } + + + public boolean isCallerSyncAdapter(Uri uri) + { + return false; + } + + + public SQLiteOpenHelper getDatabaseHelper() + { + return mOpenHelper; + } + + + private boolean applyingBatch() + { + return mApplyingBatch.get() != null && mApplyingBatch.get(); + } + + + @Override + public Uri insert(Uri uri, ContentValues values) + { + return new Profiled("Insert").run((Single) () -> + { + Uri result; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) + { + db.beginTransaction(); + try + { + result = insertInTransaction(db, uri, values, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + } + else + { + result = insertInTransaction(db, uri, values, callerIsSyncAdapter); + } + return result; + }); + } + + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) + { + return new Profiled("BulkInsert").run((Single) () -> + { + int numValues = values.length; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try + { + for (int i = 0; i < numValues; i++) + { + insertInTransaction(db, uri, values[i], callerIsSyncAdapter); + db.yieldIfContendedSafely(); + } + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + return numValues; + }); + } + + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) + { + return new Profiled("Update").run((Single) () -> + { + int count; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) + { + db.beginTransaction(); + try + { + count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + } + else + { + count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + }); + } + + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) + { + return new Profiled("Delete").run((Single) () -> + { + int count; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) + { + db.beginTransaction(); + try + { + count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + } + else + { + count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + }); + } + + + @Override + public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException + { + return new Profiled(String.format(Locale.ENGLISH, "Batch of %d operations", operations.size())).run( + (Fragile) () -> + { + int ypCount = 0; + int opCount = 0; + boolean callerIsSyncAdapter = false; + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try + { + mApplyingBatch.set(true); + final int numOperations = operations.size(); + final ContentProviderResult[] results = new ContentProviderResult[numOperations]; + for (int i = 0; i < numOperations; i++) + { + if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) + { + throw new OperationApplicationException("Too many content provider operations between yield points. " + + "The maximum number of operations per yield point is " + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); + } + final ContentProviderOperation operation = operations.get(i); + if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) + { + callerIsSyncAdapter = true; + } + if (i > 0 && operation.isYieldAllowed()) + { + opCount = 0; + if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) + { + ypCount++; + } + } + results[i] = operation.apply(this, results, i); + } + endTransaction(db); + return results; + } + finally + { + mApplyingBatch.set(false); + db.endTransaction(); + onEndTransaction(callerIsSyncAdapter); + } + }); + } + + + protected void onEndTransaction(boolean callerIsSyncAdapter) + { + Set changed; + synchronized (mChangedUris) + { + changed = new HashSet(mChangedUris); + mChangedUris.clear(); + } + ContentResolver resolver = getContext().getContentResolver(); + for (Uri uri : changed) + { + boolean syncToNetwork = !callerIsSyncAdapter && syncToNetwork(uri); + resolver.notifyChange(uri, null, syncToNetwork); + } + } + + + protected boolean syncToNetwork(Uri uri) + { + return false; + } + + + private void endTransaction(SQLiteDatabase database) + { + for (TransactionEndTask task : mTransactionEndTasks) + { + task.execute(database); + } + } + + + /** + * A {@link TransactionEndTask} which sets the transaction to be successful. + */ + private static class SuccessfulTransactionEndTask implements TransactionEndTask + { + @Override + public void execute(SQLiteDatabase database) + { + database.setTransactionSuccessful(); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java new file mode 100644 index 0000000..a78d8c7 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java @@ -0,0 +1,895 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; + +import org.dmfs.jems.optional.adapters.First; +import org.dmfs.jems.predicate.elementary.Equals; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.processors.NoOpProcessor; +import org.dmfs.provider.tasks.processors.tasks.Instantiating; +import org.dmfs.provider.tasks.utils.TableColumns; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.Property.Alarm; +import org.dmfs.tasks.contract.TaskContract.Property.Category; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.Locale; + + +/** + * Task database helper takes care of creating and updating the task database, including tables, indices and triggers. + * + * @author Marten Gajda + * @author Tobias Reinsch + */ +public class TaskDatabaseHelper extends SQLiteOpenHelper +{ + + /** + * Interface of a listener that's called when the database has been created or migrated. + */ + public interface OnDatabaseOperationListener + { + void onDatabaseCreated(SQLiteDatabase db); + + void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion); + } + + + private static final String TAG = "TaskDatabaseHelper"; + + /** + * The name of our database file. + */ + private static final String DATABASE_NAME = "tasks.db"; + + /** + * The database version. + */ + private static final int DATABASE_VERSION = 23; + + + /** + * List of all tables we provide. + */ + public interface Tables + { + String LISTS = "Lists"; + + String WRITEABLE_LISTS = "Writeable_Lists"; + + String TASKS = "Tasks"; + + String TASKS_VIEW = "Task_View"; + + String TASKS_PROPERTY_VIEW = "Task_Property_View"; + + String INSTANCES = "Instances"; + + String INSTANCE_VIEW = "Instance_View"; + + String INSTANCE_CLIENT_VIEW = "Instance_Client_View"; + + String INSTANCE_PROPERTY_VIEW = "Instance_Property_View"; + + String INSTANCE_CATEGORY_VIEW = "Instance_Cagetory_View"; + + String CATEGORIES = "Categories"; + + String CATEGORIES_MAPPING = "Categories_Mapping"; + + String PROPERTIES = "Properties"; + + String ALARMS = "Alarms"; + + String SYNCSTATE = "SyncState"; + } + + + /** + * Columns of internal table for the category mapping. + */ + public interface CategoriesMapping + { + String TASK_ID = "task_id"; + + String CATEGORY_ID = "category_id"; + + String PROPERTY_ID = "property_id"; + + } + + + /** + * SQL command to create a view that combines tasks with some data from the list they belong to. + */ + private final static String SQL_CREATE_TASK_VIEW = "create view " + Tables.TASKS_VIEW + " as select " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " from " + Tables.TASKS + " join " + Tables.LISTS + + " on (" + Tables.TASKS + "." + Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskLists._ID + ");"; + + /** + * SQL command to create a view that combines tasks with some data from the list they belong to. + */ + private final static String SQL_CREATE_TASK_PROPERTY_VIEW = "create view " + Tables.TASKS_PROPERTY_VIEW + " as select " + + Tables.TASKS + ".*, " + + Tables.PROPERTIES + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " from " + Tables.TASKS + " join " + Tables.LISTS + + " on (" + Tables.TASKS + "." + Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskLists._ID + ") " + + "left join " + Tables.PROPERTIES + " on (" + Tables.TASKS + "." + Tasks._ID + "=" + Tables.PROPERTIES + "." + Properties.TASK_ID + ");"; + + /** + * SQL command to drop the task view. + */ + private final static String SQL_DROP_TASK_VIEW = "DROP VIEW " + Tables.TASKS_VIEW + ";"; + + /** + * SQL command to create a view that combines task instances with some data from the list they belong to. + */ + private final static String SQL_CREATE_INSTANCE_VIEW = "CREATE VIEW " + Tables.INSTANCE_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; + + /** + * SQL command to create a view that combines task instances with some data from the list they belong to. This replaces the task DTSTART, DUE and + * ORIGINAL_INSTANCE_TIME values with respective values of the instance. + *

+ * This is the instances view as seen by the content provider clients. + */ + private final static String SQL_CREATE_INSTANCE_CLIENT_VIEW = "CREATE VIEW " + Tables.INSTANCE_CLIENT_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + // override task due, start and original times with the instance values + + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_START + " as " + Tasks.DTSTART + ", " + + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_DUE + " as " + Tasks.DUE + ", " + + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " as " + Tasks.ORIGINAL_INSTANCE_TIME + ", " + // override task duration with null, we already have a due + + "null as " + Tasks.DURATION + ", " + // override recurrence values with null, instances themselves are not recurring + + "null as " + Tasks.RRULE + ", " + + "null as " + Tasks.RDATE + ", " + + "null as " + Tasks.EXDATE + ", " + // this instance is part of a recurring task if either it has recurrence values or overrides an instance + + "not (" + Tasks.RRULE + " is null and " + Tasks.RDATE + " is null and " + Tasks.ORIGINAL_INSTANCE_ID + " is null and " + Tasks.ORIGINAL_INSTANCE_SYNC_ID + " is null) as " + TaskContract.Instances.IS_RECURRING + ", " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.TaskLists._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; + + /** + * SQL command to create a view that combines task instances view with the belonging properties. + */ + private final static String SQL_CREATE_INSTANCE_PROPERTY_VIEW = "CREATE VIEW " + Tables.INSTANCE_PROPERTY_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + + Tables.PROPERTIES + ".*, " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ")" + + " LEFT JOIN " + Tables.PROPERTIES + " ON (" + Tables.TASKS + "." + Tasks._ID + "=" + Tables.PROPERTIES + "." + Properties.TASK_ID + ");"; + + /** + * SQL command to create a view that combines task instances with some data from the list they belong to. + */ + private final static String SQL_CREATE_INSTANCE_CATEGORY_VIEW = "CREATE VIEW " + Tables.INSTANCE_CATEGORY_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + + Tables.CATEGORIES_MAPPING + "." + CategoriesMapping.CATEGORY_ID + ", " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ")" + + " LEFT JOIN " + Tables.CATEGORIES_MAPPING + " ON (" + Tables.CATEGORIES_MAPPING + "." + CategoriesMapping.TASK_ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; + + /** + * SQL command to drop the instance view. + */ + private final static String SQL_DROP_INSTANCE_VIEW = "DROP VIEW " + Tables.INSTANCE_VIEW + ";"; + + /** + * SQL command to drop the instance property view. + */ + //private final static String SQL_DROP_INSTANCE_PROPERTY_VIEW = "DROP VIEW " + Tables.INSTANCE_PROPERTY_VIEW + ";"; + + /** + * SQL command to create the instances table. + */ + private final static String SQL_CREATE_SYNCSTATE_TABLE = + "CREATE TABLE " + Tables.SYNCSTATE + " ( " + + TaskContract.SyncState._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TaskContract.SyncState.ACCOUNT_NAME + " TEXT, " + + TaskContract.SyncState.ACCOUNT_TYPE + " TEXT, " + + TaskContract.SyncState.DATA + " TEXT " + + ");"; + + /** + * SQL command to create the instances table. + */ + private final static String SQL_CREATE_INSTANCES_TABLE = + "CREATE TABLE " + Tables.INSTANCES + " ( " + + TaskContract.Instances._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TaskContract.Instances.TASK_ID + " INTEGER NOT NULL, " // NOT NULL + + TaskContract.Instances.INSTANCE_START + " INTEGER, " + + TaskContract.Instances.INSTANCE_DUE + " INTEGER, " + + TaskContract.Instances.INSTANCE_START_SORTING + " INTEGER, " + + TaskContract.Instances.INSTANCE_DUE_SORTING + " INTEGER, " + + TaskContract.Instances.INSTANCE_DURATION + " INTEGER, " + + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " INTEGER DEFAULT 0, " + + TaskContract.Instances.DISTANCE_FROM_CURRENT + " INTEGER DEFAULT 0);"; + + /** + * SQL command to create a trigger to clean up data of removed tasks. + */ + private final static String SQL_CREATE_TASKS_CLEANUP_TRIGGER = + "CREATE TRIGGER task_cleanup_trigger AFTER DELETE ON " + Tables.TASKS + + " BEGIN " + + " DELETE FROM " + Tables.PROPERTIES + " WHERE " + TaskContract.Properties.TASK_ID + "= old." + TaskContract.Tasks._ID + ";" + + " DELETE FROM " + Tables.INSTANCES + " WHERE " + TaskContract.Instances.TASK_ID + "=old." + TaskContract.Tasks._ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to clean up data of removed lists. + */ + private final static String SQL_CREATE_LISTS_CLEANUP_TRIGGER = + "CREATE TRIGGER list_cleanup_trigger AFTER DELETE ON " + Tables.LISTS + + " BEGIN " + + " DELETE FROM " + Tables.TASKS + " WHERE " + Tasks.LIST_ID + "= old." + TaskLists._ID + ";" + + " END;"; + + /** + * SQL command to drop the clean up trigger. + */ + private final static String SQL_DROP_TASKS_CLEANUP_TRIGGER = + "DROP TRIGGER task_cleanup_trigger;"; + + /** + * SQL command that counts and sets the alarm on deletion + */ + private final static String SQL_COUNT_ALARMS_ON_DELETE = + " BEGIN UPDATE " + Tables.TASKS + " SET " + Tasks.HAS_ALARMS + + " = (SELECT COUNT (*) FROM " + Tables.PROPERTIES + + " WHERE " + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "' AND " + Alarm.ALARM_TYPE + " <> " + Alarm.ALARM_TYPE_NOTHING + " AND " + Properties.TASK_ID + " = OLD." + Properties.TASK_ID + + ") WHERE " + Tasks._ID + " = OLD." + Properties.TASK_ID + + "; END;"; + + /** + * SQL command that counts and sets the alarm on insert and update + */ + private final static String SQL_COUNT_ALARMS = + " BEGIN UPDATE " + Tables.TASKS + " SET " + Tasks.HAS_ALARMS + + " = (SELECT COUNT (*) FROM " + Tables.PROPERTIES + + " WHERE " + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "' AND " + Alarm.ALARM_TYPE + " <> " + Alarm.ALARM_TYPE_NOTHING + " AND " + Properties.TASK_ID + " = NEW." + Properties.TASK_ID + + ") WHERE " + Tasks._ID + " = NEW." + Properties.TASK_ID + + "; END;"; + + /** + * SQL command to create a trigger that counts the alarms for a task on create + */ + private final static String SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER = + "CREATE TRIGGER alarm_count_create_trigger AFTER INSERT ON " + Tables.PROPERTIES + " WHEN NEW." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + SQL_COUNT_ALARMS; + + /** + * SQL command to create a trigger that counts the alarms for a task on update + */ + private final static String SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER = + "CREATE TRIGGER alarm_count_update_trigger AFTER UPDATE ON " + Tables.PROPERTIES + " WHEN NEW." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + SQL_COUNT_ALARMS; + + /** + * SQL command to create a trigger that counts the alarms for a task on delete + */ + private final static String SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER = + "CREATE TRIGGER alarm_count_delete_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + SQL_COUNT_ALARMS_ON_DELETE; + + /** + * SQL command to create a trigger to clean up data of removed property. + */ + private final static String SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER = + "CREATE TRIGGER alarm_property_cleanup_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + " BEGIN " + + " DELETE FROM " + Tables.ALARMS + " WHERE " + TaskContract.Alarms.ALARM_ID + "= OLD." + TaskContract.Properties.PROPERTY_ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to clean up data of removed property. + */ + private final static String SQL_CREATE_CATEGORY_PROPERTY_CLEANUP_TRIGGER = + "CREATE TRIGGER category_property_cleanup_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Category.CONTENT_ITEM_TYPE + "'" + + " BEGIN " + + " DELETE FROM " + Tables.CATEGORIES_MAPPING + " WHERE " + CategoriesMapping.PROPERTY_ID + "= OLD." + TaskContract.Properties.PROPERTY_ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to clean up property data of removed task. + */ + private final static String SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER = + "CREATE TRIGGER task_property_cleanup_trigger AFTER DELETE ON " + Tables.TASKS + " BEGIN " + + " DELETE FROM " + Tables.PROPERTIES + " WHERE " + Properties.TASK_ID + "= OLD." + Tasks._ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to increment task version number on every update. + */ + private final static String SQL_CREATE_TASK_VERSION_TRIGGER = + "CREATE TRIGGER task_version_trigger BEFORE UPDATE ON " + Tables.TASKS + " BEGIN " + + " UPDATE " + Tables.TASKS + " SET " + Tasks.VERSION + " = OLD." + Tasks.VERSION + " + 1 where " + Tasks._ID + " = NEW." + Tasks._ID + ";" + + " END;"; + + /** + * SQL command to create the task list table. + */ + private final static String SQL_CREATE_LISTS_TABLE = + "CREATE TABLE " + Tables.LISTS + " ( " + + TaskContract.TaskLists._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.TaskLists.ACCOUNT_NAME + " TEXT," + + TaskContract.TaskLists.ACCOUNT_TYPE + " TEXT," + + TaskContract.TaskLists.LIST_NAME + " TEXT," + + TaskContract.TaskLists.LIST_COLOR + " INTEGER," + + TaskContract.TaskLists.ACCESS_LEVEL + " INTEGER," + + TaskContract.TaskLists.VISIBLE + " INTEGER," + + TaskContract.TaskLists.SYNC_ENABLED + " INTEGER," + + TaskContract.TaskLists.OWNER + " TEXT," + + TaskContract.TaskLists._DIRTY + " INTEGER DEFAULT 0," + + TaskContract.TaskLists._SYNC_ID + " TEXT," + + TaskContract.TaskLists.SYNC_VERSION + " TEXT," + + TaskContract.TaskLists.SYNC1 + " TEXT," + + TaskContract.TaskLists.SYNC2 + " TEXT," + + TaskContract.TaskLists.SYNC3 + " TEXT," + + TaskContract.TaskLists.SYNC4 + " TEXT," + + TaskContract.TaskLists.SYNC5 + " TEXT," + + TaskContract.TaskLists.SYNC6 + " TEXT," + + TaskContract.TaskLists.SYNC7 + " TEXT," + + TaskContract.TaskLists.SYNC8 + " TEXT);"; + + /** + * SQL command to create the task table. + */ + private final static String SQL_CREATE_TASKS_TABLE = + "CREATE TABLE " + Tables.TASKS + " ( " + + TaskContract.Tasks._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.Tasks.VERSION + " INTEGER DEFAULT 0," + + TaskContract.Tasks.LIST_ID + " INTEGER NOT NULL, " + + TaskContract.Tasks.TITLE + " TEXT," + + TaskContract.Tasks.LOCATION + " TEXT," + + TaskContract.Tasks.GEO + " TEXT," + + TaskContract.Tasks.DESCRIPTION + " TEXT," + + TaskContract.Tasks.URL + " TEXT," + + TaskContract.Tasks.ORGANIZER + " TEXT," + + TaskContract.Tasks.PRIORITY + " INTEGER, " + + TaskContract.Tasks.TASK_COLOR + " INTEGER," + + TaskContract.Tasks.CLASSIFICATION + " INTEGER," + + TaskContract.Tasks.COMPLETED + " INTEGER," + + TaskContract.Tasks.COMPLETED_IS_ALLDAY + " INTEGER," + + TaskContract.Tasks.PERCENT_COMPLETE + " INTEGER," + + TaskContract.Tasks.STATUS + " INTEGER DEFAULT " + TaskContract.Tasks.STATUS_DEFAULT + "," + + TaskContract.Tasks.IS_NEW + " INTEGER," + + TaskContract.Tasks.IS_CLOSED + " INTEGER," + + TaskContract.Tasks.DTSTART + " INTEGER," + + TaskContract.Tasks.CREATED + " INTEGER," + + TaskContract.Tasks.LAST_MODIFIED + " INTEGER," + + TaskContract.Tasks.IS_ALLDAY + " INTEGER," + + TaskContract.Tasks.TZ + " TEXT," + + TaskContract.Tasks.DUE + " INTEGER," + + TaskContract.Tasks.DURATION + " TEXT," + + TaskContract.Tasks.RDATE + " TEXT," + + TaskContract.Tasks.EXDATE + " TEXT," + + TaskContract.Tasks.RRULE + " TEXT," + + TaskContract.Tasks.PARENT_ID + " INTEGER," + + TaskContract.Tasks.SORTING + " TEXT," + + TaskContract.Tasks.HAS_ALARMS + " INTEGER," + + TaskContract.Tasks.HAS_PROPERTIES + " INTEGER," + + TaskContract.Tasks.PINNED + " INTEGER," + + TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID + " TEXT," + + TaskContract.Tasks.ORIGINAL_INSTANCE_ID + " INTEGER," + + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME + " INTEGER," + + TaskContract.Tasks.ORIGINAL_INSTANCE_ALLDAY + " INTEGER," + + TaskContract.Tasks._DIRTY + " INTEGER DEFAULT 1," // a new task is always dirty + + TaskContract.Tasks._DELETED + " INTEGER DEFAULT 0," // new tasks are not deleted by default + + TaskContract.Tasks._SYNC_ID + " TEXT," + + TaskContract.Tasks._UID + " TEXT," + + TaskContract.Tasks.SYNC_VERSION + " TEXT," + + TaskContract.Tasks.SYNC1 + " TEXT," + + TaskContract.Tasks.SYNC2 + " TEXT," + + TaskContract.Tasks.SYNC3 + " TEXT," + + TaskContract.Tasks.SYNC4 + " TEXT," + + TaskContract.Tasks.SYNC5 + " TEXT," + + TaskContract.Tasks.SYNC6 + " TEXT," + + TaskContract.Tasks.SYNC7 + " TEXT," + + TaskContract.Tasks.SYNC8 + " TEXT);"; + + /** + * SQL command to create the categories table. + */ + private final static String SQL_CREATE_CATEGORIES_TABLE = + "CREATE TABLE " + Tables.CATEGORIES + + " ( " + TaskContract.Categories._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.Categories.ACCOUNT_NAME + " TEXT," + + TaskContract.Categories.ACCOUNT_TYPE + " TEXT," + + TaskContract.Categories.NAME + " TEXT," + + TaskContract.Categories.COLOR + " INTEGER);"; + + /** + * SQL command to create the categories table. + */ + private final static String SQL_CREATE_CATEGORIES_MAPPING_TABLE = + "CREATE TABLE " + Tables.CATEGORIES_MAPPING + + " ( " + CategoriesMapping.TASK_ID + " INTEGER," + + CategoriesMapping.CATEGORY_ID + " INTEGER," + + CategoriesMapping.PROPERTY_ID + " INTEGER," + + "FOREIGN KEY (" + CategoriesMapping.TASK_ID + ") REFERENCES " + Tables.TASKS + "(" + TaskContract.Tasks._ID + ")," + + "FOREIGN KEY (" + CategoriesMapping.PROPERTY_ID + ") REFERENCES " + Tables.PROPERTIES + "(" + TaskContract.Properties.PROPERTY_ID + ")," + + "FOREIGN KEY (" + CategoriesMapping.CATEGORY_ID + ") REFERENCES " + Tables.CATEGORIES + "(" + TaskContract.Categories._ID + "));"; + + /** + * SQL command to create the alarms table the stores the already triggered alarms. + */ + private final static String SQL_CREATE_ALARMS_TABLE = + "CREATE TABLE " + Tables.ALARMS + + " ( " + TaskContract.Alarms.ALARM_ID + " INTEGER," + + TaskContract.Alarms.LAST_TRIGGER + " TEXT," + + TaskContract.Alarms.NEXT_TRIGGER + " TEXT);"; + + /** + * SQL command to create the table for extended properties. + */ + private final static String SQL_CREATE_PROPERTIES_TABLE = + "CREATE TABLE " + Tables.PROPERTIES + " ( " + + TaskContract.Properties.PROPERTY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.Properties.TASK_ID + " INTEGER," + + TaskContract.Properties.MIMETYPE + " INTEGER," + + TaskContract.Properties.VERSION + " INTEGER," + + TaskContract.Properties.DATA0 + " TEXT," + + TaskContract.Properties.DATA1 + " TEXT," + + TaskContract.Properties.DATA2 + " TEXT," + + TaskContract.Properties.DATA3 + " TEXT," + + TaskContract.Properties.DATA4 + " TEXT," + + TaskContract.Properties.DATA5 + " TEXT," + + TaskContract.Properties.DATA6 + " TEXT," + + TaskContract.Properties.DATA7 + " TEXT," + + TaskContract.Properties.DATA8 + " TEXT," + + TaskContract.Properties.DATA9 + " TEXT," + + TaskContract.Properties.DATA10 + " TEXT," + + TaskContract.Properties.DATA11 + " TEXT," + + TaskContract.Properties.DATA12 + " TEXT," + + TaskContract.Properties.DATA13 + " TEXT," + + TaskContract.Properties.DATA14 + " TEXT," + + TaskContract.Properties.DATA15 + " TEXT," + + TaskContract.Properties.SYNC1 + " TEXT," + + TaskContract.Properties.SYNC2 + " TEXT," + + TaskContract.Properties.SYNC3 + " TEXT," + + TaskContract.Properties.SYNC4 + " TEXT," + + TaskContract.Properties.SYNC5 + " TEXT," + + TaskContract.Properties.SYNC6 + " TEXT," + + TaskContract.Properties.SYNC7 + " TEXT," + + TaskContract.Properties.SYNC8 + " TEXT);"; + + /** + * SQL command to drop the task view. + */ + private final static String SQL_DROP_PROPERTIES_TABLE = "DROP TABLE " + Tables.PROPERTIES + ";"; + + + /** + * Builds a string that creates an index on the given table for the given columns. + * + * @param table + * The table to create the index on. + * @param fields + * The fields to index. + * + * @return An SQL command string. + */ + public static String createIndexString(String table, boolean unique, String... fields) + { + if (fields == null || fields.length < 1) + { + throw new IllegalArgumentException("need at least one field to build an index!"); + } + + StringBuffer buffer = new StringBuffer(); + + // Index name is constructed like this: tablename_fields[0]_idx + buffer.append("CREATE "); + if (unique) + { + buffer.append(" UNIQUE "); + } + buffer.append("INDEX IF NOT EXISTS "); + buffer.append(table).append("_").append(fields[0]).append("_idx ON "); + buffer.append(table).append(" ("); + buffer.append(fields[0]); + for (int i = 1; i < fields.length; i++) + { + buffer.append(", ").append(fields[i]); + } + buffer.append(");"); + + return buffer.toString(); + + } + + + private final OnDatabaseOperationListener mListener; + + + TaskDatabaseHelper(Context context, OnDatabaseOperationListener listener) + { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + mListener = listener; + } + + + /** + * Creates the tables, views, triggers and indices. + *

+ * TODO: move all strings to separate final static variables. + */ + @Override + public void onCreate(SQLiteDatabase db) + { + + // create task list table + db.execSQL(SQL_CREATE_LISTS_TABLE); + + // trigger that removes tasks of a list that has been removed + db.execSQL("CREATE TRIGGER task_list_cleanup_trigger AFTER DELETE ON " + Tables.LISTS + " BEGIN DELETE FROM " + Tables.TASKS + " WHERE " + + TaskContract.Tasks.LIST_ID + "= old." + TaskContract.TaskLists._ID + "; END"); + + // create task table + db.execSQL(SQL_CREATE_TASKS_TABLE); + + // trigger that marks a list as dirty if a task in that list gets marked as dirty or deleted + db.execSQL("CREATE TRIGGER task_list_make_dirty_on_update AFTER UPDATE ON " + Tables.TASKS + " BEGIN UPDATE " + Tables.LISTS + " SET " + + TaskContract.TaskLists._DIRTY + "=" + TaskContract.TaskLists._DIRTY + " + " + "new." + TaskContract.Tasks._DIRTY + " + " + "new." + + TaskContract.Tasks._DELETED + " WHERE " + TaskContract.TaskLists._ID + "= new." + TaskContract.Tasks.LIST_ID + "; END"); + + // trigger that marks a list as dirty if a task in that list gets marked as dirty or deleted + db.execSQL("CREATE TRIGGER task_list_make_dirty_on_insert AFTER INSERT ON " + Tables.TASKS + " BEGIN UPDATE " + Tables.LISTS + " SET " + + TaskContract.TaskLists._DIRTY + "=" + TaskContract.TaskLists._DIRTY + " + " + "new." + TaskContract.Tasks._DIRTY + " + " + "new." + + TaskContract.Tasks._DELETED + " WHERE " + TaskContract.TaskLists._ID + "= new." + TaskContract.Tasks.LIST_ID + "; END"); + + // create task version update trigger + db.execSQL(SQL_CREATE_TASK_VERSION_TRIGGER); + + // create instances table and view + db.execSQL(SQL_CREATE_INSTANCES_TABLE); + + // create categories table + db.execSQL(SQL_CREATE_CATEGORIES_TABLE); + + // create categories mapping table + db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); + + // create alarms table + db.execSQL(SQL_CREATE_ALARMS_TABLE); + + // create properties table + db.execSQL(SQL_CREATE_PROPERTIES_TABLE); + + // create syncstate table + db.execSQL(SQL_CREATE_SYNCSTATE_TABLE); + + // create views + db.execSQL(SQL_CREATE_TASK_VIEW); + db.execSQL(SQL_CREATE_TASK_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_CATEGORY_VIEW); + + // create indices + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.TASK_ID, TaskContract.Instances.INSTANCE_START, + TaskContract.Instances.INSTANCE_DUE)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_START_SORTING)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_DUE_SORTING)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_ORIGINAL_TIME)); + db.execSQL(createIndexString(Tables.LISTS, false, TaskContract.TaskLists.ACCOUNT_NAME, // not sure if necessary + TaskContract.TaskLists.ACCOUNT_TYPE)); + db.execSQL(createIndexString(Tables.TASKS, false, TaskContract.Tasks.STATUS, TaskContract.Tasks.LIST_ID, TaskContract.Tasks._SYNC_ID)); + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.MIMETYPE, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.ACCOUNT_NAME, TaskContract.Categories.ACCOUNT_TYPE, + TaskContract.Categories.NAME)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.NAME)); + db.execSQL(createIndexString(Tables.SYNCSTATE, true, TaskContract.SyncState.ACCOUNT_NAME, TaskContract.SyncState.ACCOUNT_TYPE)); + + // trigger that removes properties of a task that has been removed + db.execSQL(SQL_CREATE_TASKS_CLEANUP_TRIGGER); + + // trigger that removes alarms when an alarm property was deleted + db.execSQL(SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER); + + // trigger that removes tasks when a list was removed + db.execSQL(SQL_CREATE_LISTS_CLEANUP_TRIGGER); + + // trigger that counts the alarms for tasks + db.execSQL(SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER); + + // add cleanup trigger for orphaned properties + db.execSQL(SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER); + + // initialize FTS + FTSDatabaseHelper.onCreate(db); + + if (mListener != null) + { + mListener.onDatabaseCreated(db); + } + } + + + /** + * Manages the database schema migration. + */ + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) + { + Log.i(TAG, "updgrading db from " + oldVersion + " to " + newVersion); + if (oldVersion < 2) + { + // add IS_NEW and IS_CLOSED columns and update their values + db.execSQL("ALTER TABLE " + Tables.TASKS + " ADD COLUMN " + TaskContract.Tasks.IS_NEW + " INTEGER"); + db.execSQL("ALTER TABLE " + Tables.TASKS + " ADD COLUMN " + TaskContract.Tasks.IS_CLOSED + " INTEGER"); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_NEW + " = 1 WHERE " + TaskContract.Tasks.STATUS + " = " + + TaskContract.Tasks.STATUS_NEEDS_ACTION); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_NEW + " = 0 WHERE " + TaskContract.Tasks.STATUS + " != " + + TaskContract.Tasks.STATUS_NEEDS_ACTION); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_CLOSED + " = 1 WHERE " + TaskContract.Tasks.STATUS + " > " + + TaskContract.Tasks.STATUS_IN_PROCESS); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_CLOSED + " = 0 WHERE " + TaskContract.Tasks.STATUS + " <= " + + TaskContract.Tasks.STATUS_IN_PROCESS); + } + + if (oldVersion < 3) + { + // add instance sortings + db.execSQL("ALTER TABLE " + Tables.INSTANCES + " ADD COLUMN " + TaskContract.Instances.INSTANCE_START_SORTING + " INTEGER"); + db.execSQL("ALTER TABLE " + Tables.INSTANCES + " ADD COLUMN " + TaskContract.Instances.INSTANCE_DUE_SORTING + " INTEGER"); + db.execSQL("UPDATE " + Tables.INSTANCES + " SET " + TaskContract.Instances.INSTANCE_START_SORTING + " = " + TaskContract.Instances.INSTANCE_START + + ", " + TaskContract.Instances.INSTANCE_DUE_SORTING + " = " + TaskContract.Instances.INSTANCE_DUE); + } + if (oldVersion < 4) + { + // drop old view before altering the schema + db.execSQL(SQL_DROP_TASK_VIEW); + db.execSQL(SQL_DROP_INSTANCE_VIEW); + + // change property id column name to work with the left join in task view + db.execSQL(SQL_DROP_TASKS_CLEANUP_TRIGGER); + db.execSQL(SQL_DROP_PROPERTIES_TABLE); + db.execSQL(SQL_CREATE_PROPERTIES_TABLE); + db.execSQL(SQL_CREATE_TASKS_CLEANUP_TRIGGER); + + // create categories mapping table + db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); + + // create alarms table + db.execSQL(SQL_CREATE_ALARMS_TABLE); + + // update views + db.execSQL(SQL_CREATE_TASK_VIEW); + db.execSQL(SQL_CREATE_TASK_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_CATEGORY_VIEW); + + // create Indices + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.MIMETYPE, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.ACCOUNT_NAME, TaskContract.Categories.ACCOUNT_TYPE, + TaskContract.Categories.NAME)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.NAME)); + + // add new triggers + db.execSQL(SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER); + + } + if (oldVersion < 6) + { + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.PARENT_ID + " integer;"); + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.HAS_ALARMS + " integer;"); + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.SORTING + " text;"); + } + if (oldVersion < 7) + { + db.execSQL(SQL_CREATE_LISTS_CLEANUP_TRIGGER); + } + if (oldVersion < 8) + { + // replace priority 0 by null. We need this to sort the widget properly. Since 0 is the default this is no problem when syncing. + db.execSQL("update " + Tables.TASKS + " set " + Tasks.PRIORITY + "=null where " + Tasks.PRIORITY + "=0;"); + } + if (oldVersion < 9) + { + // add missing column _UID + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks._UID + " integer;"); + // add cleanup trigger for orphaned properties + db.execSQL(SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER); + } + if (oldVersion < 10) + { + // add property column to categories_mapping table. Since adding a constraint is not supported by SQLite we have to remove and recreate the entire + // table + db.execSQL("drop table " + Tables.CATEGORIES_MAPPING); + db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); + db.execSQL(SQL_CREATE_CATEGORY_PROPERTY_CLEANUP_TRIGGER); + } + if (oldVersion < 11) + { + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.PINNED + " integer;"); + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.HAS_PROPERTIES + " integer;"); + } + + if (oldVersion < 12) + { + // rename the local account type + ContentValues values = new ContentValues(1); + values.put(TaskLists.ACCOUNT_TYPE, TaskContract.LOCAL_ACCOUNT_TYPE); + db.update(Tables.LISTS, values, TaskLists.ACCOUNT_TYPE + "=?", new String[] { "LOCAL" }); + } + + if (oldVersion < 13) + { + db.execSQL(SQL_CREATE_SYNCSTATE_TABLE); + } + + if (oldVersion < 14) + { + // create a unique index for account name and account type on the sync state table + db.execSQL(createIndexString(Tables.SYNCSTATE, true, TaskContract.SyncState.ACCOUNT_NAME, TaskContract.SyncState.ACCOUNT_TYPE)); + } + + if (oldVersion < 16) + { + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_START_SORTING)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_DUE_SORTING)); + } + + if (oldVersion < 17) + { + db.execSQL("alter table " + Tables.INSTANCES + " add column " + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " integer default 0;"); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_ORIGINAL_TIME)); + } + + if (oldVersion < 18) + { + db.execSQL("alter table " + Tables.INSTANCES + " add column " + TaskContract.Instances.DISTANCE_FROM_CURRENT + " integer default 0;"); + } + + if (oldVersion < 19) + { + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + } + + if (oldVersion < 22) + { + // create version column, unless it already exists + if (!new First<>(new TableColumns(Tables.TASKS).value(db), new Equals<>(Tasks.VERSION)).isPresent()) + { + // create task version column and update trigger + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.VERSION + " Integer default 0;"); + db.execSQL(SQL_CREATE_TASK_VERSION_TRIGGER); + } + } + + if (oldVersion < 22) + { + db.beginTransaction(); + try + { + // make sure we upgrade the instances of every recurring task + EntityProcessor processor = new Instantiating(new NoOpProcessor<>()); + try (Cursor c = db.query(Tables.TASKS, + new String[] { + TaskContract.Tasks._ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_CLOSED, Tasks.TZ, + Tasks.IS_ALLDAY, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.ORIGINAL_INSTANCE_TIME, Tasks.ORIGINAL_INSTANCE_ALLDAY }, + String.format(Locale.ENGLISH, "%s is null", TaskContract.Tasks.ORIGINAL_INSTANCE_ID), + null, null, null, null)) + { + while (c.moveToNext()) + { + ContentValues values = new ContentValues(); + Instantiating.addUpdateRequest(values); + TaskAdapter adapter = new CursorContentValuesTaskAdapter(c, values); + processor.update(db, adapter, false); + } + } + db.setTransactionSuccessful(); + } + finally + { + db.endTransaction(); + } + } + + if (oldVersion < 23) + { + db.execSQL("drop view " + Tables.INSTANCE_CLIENT_VIEW + ";"); + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + } + + // upgrade FTS + FTSDatabaseHelper.onUpgrade(db, oldVersion, newVersion); + + if (mListener != null) + { + mListener.onDatabaseUpdate(db, oldVersion, newVersion); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java new file mode 100644 index 0000000..6bddccd --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java @@ -0,0 +1,1408 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.OnAccountsUpdateListener; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.text.TextUtils; +import android.util.Log; + +import org.dmfs.iterables.EmptyIterable; +import org.dmfs.provider.tasks.TaskDatabaseHelper.OnDatabaseOperationListener; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.provider.tasks.handler.PropertyHandler; +import org.dmfs.provider.tasks.handler.PropertyHandlerFactory; +import org.dmfs.provider.tasks.model.ContentValuesListAdapter; +import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesListAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.ListAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.processors.instances.Detaching; +import org.dmfs.provider.tasks.processors.instances.TaskValueDelegate; +import org.dmfs.provider.tasks.processors.lists.ListCommitProcessor; +import org.dmfs.provider.tasks.processors.tasks.AutoCompleting; +import org.dmfs.provider.tasks.processors.tasks.Instantiating; +import org.dmfs.provider.tasks.processors.tasks.Moving; +import org.dmfs.provider.tasks.processors.tasks.Originating; +import org.dmfs.provider.tasks.processors.tasks.Relating; +import org.dmfs.provider.tasks.processors.tasks.Reparenting; +import org.dmfs.provider.tasks.processors.tasks.Searchable; +import org.dmfs.provider.tasks.processors.tasks.TaskCommitProcessor; +import org.dmfs.provider.tasks.processors.tasks.Validating; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Alarms; +import org.dmfs.tasks.contract.TaskContract.Categories; +import org.dmfs.tasks.contract.TaskContract.CategoriesColumns; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.PropertyColumns; +import org.dmfs.tasks.contract.TaskContract.SyncState; +import org.dmfs.tasks.contract.TaskContract.TaskColumns; +import org.dmfs.tasks.contract.TaskContract.TaskListColumns; +import org.dmfs.tasks.contract.TaskContract.TaskListSyncColumns; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + + +/** + * The provider for tasks. + *

+ * TODO: add support for recurring tasks + *

+ * TODO: add support for reminders + *

+ * TODO: add support for attendees + *

+ * TODO: refactor the selection stuff + * + * @author Marten Gajda + * @author Tobias Reinsch + */ +public final class TaskProvider extends SQLiteContentProvider implements OnAccountsUpdateListener, OnDatabaseOperationListener +{ + + private static final int LISTS = 1; + private static final int LIST_ID = 2; + private static final int TASKS = 101; + private static final int TASK_ID = 102; + private static final int INSTANCES = 103; + private static final int INSTANCE_ID = 104; + private static final int CATEGORIES = 1001; + private static final int CATEGORY_ID = 1002; + private static final int PROPERTIES = 1003; + private static final int PROPERTY_ID = 1004; + private static final int ALARMS = 1005; + private static final int ALARM_ID = 1006; + private static final int SEARCH = 1007; + private static final int SYNCSTATE = 1008; + private static final int SYNCSTATE_ID = 1009; + + private static final int OPERATIONS = 100000; + + private final static Set TASK_LIST_SYNC_COLUMNS = new HashSet(Arrays.asList(TaskLists.SYNC_ADAPTER_COLUMNS)); + private static final String TAG = "TaskProvider"; + + /** + * A list of {@link EntityProcessor}s to execute when doing operations on the instances table. + */ + private EntityProcessor mInstanceProcessorChain; + + /** + * A list of {@link EntityProcessor}s to execute when doing operations on the tasks table. + */ + private EntityProcessor mTaskProcessorChain; + + /** + * A list of {@link EntityProcessor}s to execute when doing operations on the task lists table. + */ + private EntityProcessor mListProcessorChain; + + /** + * Our authority. + */ + String mAuthority; + + /** + * The {@link UriMatcher} we use. + */ + private UriMatcher mUriMatcher; + + /** + * A handler to execute asynchronous jobs. + */ + Handler mAsyncHandler; + + /** + * Boolean to track if there are changes within a transaction. + *

+ * This can be shared by multiple threads, hence the {@link AtomicBoolean}. + */ + private AtomicBoolean mChanged = new AtomicBoolean(false); + + /** + * This is a per transaction/thread flag which indicates whether new lists with an unknown account have been added. + * If this holds true at the end of a transaction a window should be shown to ask the user for access to that account. + */ + private ThreadLocal mStaleListCreated = new ThreadLocal<>(); + + /** + * The currently known accounts. This may be accessed from various threads, hence the AtomicReference. + * By statring with an empty set, we can always guarantee a non-null reference. + */ + private AtomicReference> mAccountCache = new AtomicReference<>(Collections.emptySet()); + + + public TaskProvider() + { + // for now we don't have anything specific to execute before the transaction ends. + super(EmptyIterable.instance()); + } + + + @Override + public boolean onCreate() + { + mAuthority = AuthorityUtil.taskAuthority(getContext()); + + mTaskProcessorChain = new Validating( + new AutoCompleting(new Relating(new Reparenting(new Instantiating(new Searchable(new Moving(new Originating(new TaskCommitProcessor())))))))); + + mListProcessorChain = new org.dmfs.provider.tasks.processors.lists.Validating(new ListCommitProcessor()); + + mInstanceProcessorChain = new org.dmfs.provider.tasks.processors.instances.Validating( + new Detaching(new TaskValueDelegate(mTaskProcessorChain), mTaskProcessorChain)); + + mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH, LISTS); + + mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH + "/#", LIST_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Tasks.CONTENT_URI_PATH, TASKS); + mUriMatcher.addURI(mAuthority, TaskContract.Tasks.CONTENT_URI_PATH + "/#", TASK_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Instances.CONTENT_URI_PATH, INSTANCES); + mUriMatcher.addURI(mAuthority, TaskContract.Instances.CONTENT_URI_PATH + "/#", INSTANCE_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Properties.CONTENT_URI_PATH, PROPERTIES); + mUriMatcher.addURI(mAuthority, TaskContract.Properties.CONTENT_URI_PATH + "/#", PROPERTY_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Categories.CONTENT_URI_PATH, CATEGORIES); + mUriMatcher.addURI(mAuthority, TaskContract.Categories.CONTENT_URI_PATH + "/#", CATEGORY_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Alarms.CONTENT_URI_PATH, ALARMS); + mUriMatcher.addURI(mAuthority, TaskContract.Alarms.CONTENT_URI_PATH + "/#", ALARM_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Tasks.SEARCH_URI_PATH, SEARCH); + + mUriMatcher.addURI(mAuthority, TaskContract.SyncState.CONTENT_URI_PATH, SYNCSTATE); + mUriMatcher.addURI(mAuthority, TaskContract.SyncState.CONTENT_URI_PATH + "/#", SYNCSTATE_ID); + + ContentOperation.register(mUriMatcher, mAuthority, OPERATIONS); + + boolean result = super.onCreate(); + + // create a HandlerThread to perform async operations + HandlerThread thread = new HandlerThread("backgroundHandler"); + thread.start(); + mAsyncHandler = new Handler(thread.getLooper()); + + AccountManager accountManager = AccountManager.get(getContext()); + accountManager.addOnAccountsUpdatedListener(this, mAsyncHandler, true); + + updateNotifications(); + + return result; + } + + + /** + * Return true if the caller is a sync adapter (i.e. if the Uri contains the query parameter {@link TaskContract#CALLER_IS_SYNCADAPTER} and its value is + * true). + * + * @param uri + * The {@link Uri} to check. + * + * @return true if the caller pretends to be a sync adapter, false otherwise. + */ + @Override + public boolean isCallerSyncAdapter(Uri uri) + { + String param = uri.getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER); + return param != null && !"false".equals(param); + } + + + /** + * Return true if the URI indicates to a load extended properties with {@link TaskContract#LOAD_PROPERTIES}. + * + * @param uri + * The {@link Uri} to check. + * + * @return true if the URI requests to load extended properties, false otherwise. + */ + public boolean shouldLoadProperties(Uri uri) + { + String param = uri.getQueryParameter(TaskContract.LOAD_PROPERTIES); + return param != null && !"false".equals(param); + } + + + /** + * Get the account name from the given {@link Uri}. + * + * @param uri + * The Uri to check. + * + * @return The account name or null if no account name has been specified. + */ + protected String getAccountName(Uri uri) + { + return uri.getQueryParameter(TaskContract.ACCOUNT_NAME); + } + + + /** + * Get the account type from the given {@link Uri}. + * + * @param uri + * The Uri to check. + * + * @return The account type or null if no account type has been specified. + */ + protected String getAccountType(Uri uri) + { + return uri.getQueryParameter(TaskContract.ACCOUNT_TYPE); + } + + + /** + * Get any id from the given {@link Uri}. + * + * @param uri + * The Uri. + * + * @return The last path segment (which should contain the id). + */ + private long getId(Uri uri) + { + return Long.parseLong(uri.getPathSegments().get(1)); + } + + + /** + * Build a selection string that selects the account specified in uri. + * + * @param uri + * A {@link Uri} that specifies an account. + * + * @return A {@link StringBuilder} with a selection string for the account. + */ + protected StringBuilder selectAccount(Uri uri) + { + StringBuilder sb = new StringBuilder(256); + return selectAccount(sb, uri); + } + + + /** + * Append the selection of the account specified in uri to the {@link StringBuilder} sb. + * + * @param sb + * A {@link StringBuilder} that the selection is appended to. + * @param uri + * A {@link Uri} that specifies an account. + * + * @return sb. + */ + protected StringBuilder selectAccount(StringBuilder sb, Uri uri) + { + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + if (accountName != null || accountType != null) + { + + if (accountName != null) + { + if (sb.length() > 0) + { + sb.append(" AND "); + } + + sb.append(TaskListSyncColumns.ACCOUNT_NAME); + sb.append("="); + DatabaseUtils.appendEscapedSQLString(sb, accountName); + } + if (accountType != null) + { + + if (sb.length() > 0) + { + sb.append(" AND "); + } + + sb.append(TaskListSyncColumns.ACCOUNT_TYPE); + sb.append("="); + DatabaseUtils.appendEscapedSQLString(sb, accountType); + } + } + return sb; + } + + + /** + * Append the selection of the account specified in uri to the an {@link SQLiteQueryBuilder}. + * + * @param sqlBuilder + * A {@link SQLiteQueryBuilder} that the selection is appended to. + * @param uri + * A {@link Uri} that specifies an account. + */ + protected void selectAccount(SQLiteQueryBuilder sqlBuilder, Uri uri) + { + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + if (accountName != null) + { + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(TaskListSyncColumns.ACCOUNT_NAME); + sqlBuilder.appendWhere("="); + sqlBuilder.appendWhereEscapeString(accountName); + } + if (accountType != null) + { + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(TaskListSyncColumns.ACCOUNT_TYPE); + sqlBuilder.appendWhere("="); + sqlBuilder.appendWhereEscapeString(accountType); + } + } + + + private StringBuilder _selectId(StringBuilder sb, long id, String key) + { + if (sb.length() > 0) + { + sb.append(" AND "); + } + sb.append(key); + sb.append("="); + sb.append(id); + return sb; + } + + + protected StringBuilder selectId(Uri uri) + { + StringBuilder sb = new StringBuilder(128); + return selectId(sb, uri); + } + + + protected StringBuilder selectId(StringBuilder sb, Uri uri) + { + return _selectId(sb, getId(uri), TaskListColumns._ID); + } + + + protected StringBuilder selectTaskId(Uri uri) + { + StringBuilder sb = new StringBuilder(128); + return selectTaskId(sb, uri); + } + + + protected StringBuilder selectTaskId(long id) + { + StringBuilder sb = new StringBuilder(128); + return selectTaskId(sb, id); + } + + + protected StringBuilder selectTaskId(StringBuilder sb, Uri uri) + { + return selectTaskId(sb, getId(uri)); + } + + + protected StringBuilder selectTaskId(StringBuilder sb, long id) + { + return _selectId(sb, id, Instances.TASK_ID); + + } + + + protected StringBuilder selectPropertyId(Uri uri) + { + StringBuilder sb = new StringBuilder(128); + return selectPropertyId(sb, uri); + } + + + protected StringBuilder selectPropertyId(StringBuilder sb, Uri uri) + { + return selectPropertyId(sb, getId(uri)); + } + + + protected StringBuilder selectPropertyId(long id) + { + StringBuilder sb = new StringBuilder(128); + return selectPropertyId(sb, id); + } + + + protected StringBuilder selectPropertyId(StringBuilder sb, long id) + { + return _selectId(sb, id, PropertyColumns.PROPERTY_ID); + } + + + /** + * Add a selection by ID to the given {@link SQLiteQueryBuilder}. The id is taken from the given Uri. + * + * @param sqlBuilder + * The {@link SQLiteQueryBuilder} to append the selection to. + * @param idColumn + * The column that must match the id. + * @param uri + * An {@link Uri} that contains the id. + */ + protected void selectId(SQLiteQueryBuilder sqlBuilder, String idColumn, Uri uri) + { + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(idColumn); + sqlBuilder.appendWhere("="); + sqlBuilder.appendWhere(String.valueOf(getId(uri))); + } + + + /** + * Append any arbitrary selection string to the selection in sb + * + * @param sb + * A {@link StringBuilder} that already contains a selection string. + * @param selection + * A valid SQL selection string. + * + * @return A string with the final selection. + */ + protected String updateSelection(StringBuilder sb, String selection) + { + if (selection != null) + { + if (sb.length() > 0) + { + sb.append(" AND ( ").append(selection).append(" ) "); + } + else + { + sb.append(" ( ").append(selection).append(" ) "); + } + } + return sb.toString(); + } + + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) + { + final SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + SQLiteQueryBuilder sqlBuilder = new SQLiteQueryBuilder(); + // initialize appendWhere, this allows us to append all other selections with a preceding "AND" + sqlBuilder.appendWhere(" 1=1 "); + boolean isSyncAdapter = isCallerSyncAdapter(uri); + + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE_ID: + // the id is ignored, we only match by account type and name given in the Uri + case SYNCSTATE: + { + if (TextUtils.isEmpty(getAccountName(uri)) || TextUtils.isEmpty(getAccountType(uri))) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.SYNCSTATE); + break; + } + case LISTS: + // add account to selection if any + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.LISTS); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.TaskLists.DEFAULT_SORT_ORDER; + } + break; + + case LIST_ID: + // add account to selection if any + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.LISTS); + selectId(sqlBuilder, TaskListColumns._ID, uri); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.TaskLists.DEFAULT_SORT_ORDER; + } + break; + + case TASKS: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to task view that includes these properties + sqlBuilder.setTables(Tables.TASKS_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.TASKS_VIEW); + } + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Tasks.DEFAULT_SORT_ORDER; + } + break; + + case TASK_ID: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to task view that includes these properties + sqlBuilder.setTables(Tables.TASKS_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.TASKS_VIEW); + } + selectId(sqlBuilder, TaskColumns._ID, uri); + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Tasks.DEFAULT_SORT_ORDER; + } + break; + + case INSTANCES: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to instance view that includes these properties + sqlBuilder.setTables(Tables.INSTANCE_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.INSTANCE_CLIENT_VIEW); + } + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Instances.DEFAULT_SORT_ORDER; + } + break; + + case INSTANCE_ID: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to instance view that includes these properties + sqlBuilder.setTables(Tables.INSTANCE_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.INSTANCE_CLIENT_VIEW); + } + selectId(sqlBuilder, Instances._ID, uri); + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Instances.DEFAULT_SORT_ORDER; + } + break; + + case CATEGORIES: + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.CATEGORIES); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Categories.DEFAULT_SORT_ORDER; + } + break; + + case CATEGORY_ID: + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.CATEGORIES); + selectId(sqlBuilder, CategoriesColumns._ID, uri); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Categories.DEFAULT_SORT_ORDER; + } + break; + + case PROPERTIES: + sqlBuilder.setTables(Tables.PROPERTIES); + break; + + case PROPERTY_ID: + sqlBuilder.setTables(Tables.PROPERTIES); + selectId(sqlBuilder, PropertyColumns.PROPERTY_ID, uri); + break; + + case SEARCH: + String searchString = uri.getQueryParameter(Tasks.SEARCH_QUERY_PARAMETER); + searchString = Uri.decode(searchString); + Cursor searchCursor = FTSDatabaseHelper.getTaskSearchCursor(db, searchString, projection, selection, selectionArgs, sortOrder); + if (searchCursor != null) + { + // attach tasks uri for notifications, that way the search results are updated when a task changes + searchCursor.setNotificationUri(getContext().getContentResolver(), Tasks.getContentUri(mAuthority)); + } + return searchCursor; + + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + Cursor c = sqlBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); + + if (c != null) + { + c.setNotificationUri(getContext().getContentResolver(), uri); + } + return c; + } + + + @Override + public int deleteInTransaction(final SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs, final boolean isSyncAdapter) + { + int count = 0; + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE_ID: + // the id is ignored, we only match by account type and name given in the Uri + case SYNCSTATE: + { + if (!isSyncAdapter) + { + throw new IllegalAccessError("only sync adapters may access syncstate"); + } + if (TextUtils.isEmpty(getAccountName(uri)) || TextUtils.isEmpty(getAccountType(uri))) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + selection = updateSelection(selectAccount(uri), selection); + count = db.delete(Tables.SYNCSTATE, selection, selectionArgs); + break; + } + /* + * Deleting task lists is only allowed to sync adapters. They must provide ACCOUNT_NAME and ACCOUNT_TYPE. + */ + case LIST_ID: + // add _id to selection and fall through + selection = updateSelection(selectId(uri), selection); + case LISTS: + { + if (isSyncAdapter) + { + if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) + { + throw new IllegalArgumentException("Sync adapters must specify an account and account type: " + uri); + } + } + + // iterate over all lists that match the selection + final Cursor cursor = db.query(Tables.LISTS, null, selection, selectionArgs, null, null, null, null); + + try + { + while (cursor.moveToNext()) + { + final ListAdapter list = new CursorContentValuesListAdapter(ListAdapter._ID.getFrom(cursor), cursor, new ContentValues()); + + mListProcessorChain.delete(db, list, isSyncAdapter); + mChanged.set(true); + count++; + } + } + finally + { + cursor.close(); + } + + break; + + } + /* + * Task won't be removed, just marked as deleted if the caller isn't a sync adapter. Sync adapters can remove tasks immediately. + */ + case TASK_ID: + // add id to selection and fall through + selection = updateSelection(selectId(uri), selection); + + case TASKS: + { + // TODO: filter by account name and type if present in uri. + + if (isSyncAdapter) + { + if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) + { + throw new IllegalArgumentException("Sync adapters must specify an account and account type: " + uri); + } + } + + // iterate over all tasks that match the selection + final Cursor cursor = db.query(Tables.TASKS_VIEW, null, selection, selectionArgs, null, null, null, null); + + try + { + while (cursor.moveToNext()) + { + final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, new ContentValues()); + + mTaskProcessorChain.delete(db, task, isSyncAdapter); + + mChanged.set(true); + count++; + } + } + finally + { + cursor.close(); + } + + break; + } + + case INSTANCE_ID: + // add id to selection and fall through + selection = updateSelection(selectId(uri), selection); + + case INSTANCES: + { + // iterate over all instances that match the selection + try (Cursor cursor = db.query(Tables.INSTANCE_VIEW, null, selection, selectionArgs, null, null, null, null)) + { + while (cursor.moveToNext()) + { + mInstanceProcessorChain.delete(db, new CursorContentValuesInstanceAdapter(cursor, new ContentValues()), isSyncAdapter); + mChanged.set(true); + count++; + } + } + + break; + } + + case ALARM_ID: + // add id to selection and fall through + selection = updateSelection(selectId(uri), selection); + + case ALARMS: + + count = db.delete(Tables.ALARMS, selection, selectionArgs); + break; + + case PROPERTY_ID: + selection = updateSelection(selectPropertyId(uri), selection); + + case PROPERTIES: + // fetch all properties that match the selection + Cursor cursor = db.query(Tables.PROPERTIES, null, selection, selectionArgs, null, null, null); + + try + { + int propIdCol = cursor.getColumnIndex(Properties.PROPERTY_ID); + int taskIdCol = cursor.getColumnIndex(Properties.TASK_ID); + int mimeTypeCol = cursor.getColumnIndex(Properties.MIMETYPE); + while (cursor.moveToNext()) + { + long propertyId = cursor.getLong(propIdCol); + long taskId = cursor.getLong(taskIdCol); + String mimeType = cursor.getString(mimeTypeCol); + if (mimeType != null) + { + PropertyHandler handler = PropertyHandlerFactory.get(mimeType); + count += handler.delete(db, taskId, propertyId, cursor, isSyncAdapter); + } + } + } + finally + { + cursor.close(); + } + postNotifyUri(Properties.getContentUri(mAuthority)); + break; + + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + if (count > 0) + { + postNotifyUri(uri); + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + } + return count; + } + + + @Override + public Uri insertInTransaction(final SQLiteDatabase db, Uri uri, final ContentValues values, final boolean isSyncAdapter) + { + long rowId; + Uri result_uri; + + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE: + { + if (!isSyncAdapter) + { + throw new IllegalAccessError("only sync adapters may access syncstate"); + } + if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + values.put(SyncState.ACCOUNT_NAME, accountName); + values.put(SyncState.ACCOUNT_TYPE, accountType); + rowId = db.replace(Tables.SYNCSTATE, null, values); + result_uri = TaskContract.SyncState.getContentUri(mAuthority); + break; + } + case LISTS: + { + final ListAdapter list = new ContentValuesListAdapter(values); + list.set(ListAdapter.ACCOUNT_NAME, accountName); + list.set(ListAdapter.ACCOUNT_TYPE, accountType); + + mListProcessorChain.insert(db, list, isSyncAdapter); + mChanged.set(true); + + rowId = list.id(); + result_uri = TaskContract.TaskLists.getContentUri(mAuthority); + // if the account is unknown we need to ask the user + // + // AGENDULA CHANGE: also require the type to be one we authenticate ourselves, for + // the same reason Utils.cleanUpLists does. Without GET_ACCOUNTS the cache only ever + // holds our own accounts, so upstream's test would call *every* externally-synced + // list stale and fire a broadcast about it on each insert. Nothing listens today; + // the point is that whatever listens tomorrow gets a signal that means something. + if (Build.VERSION.SDK_INT >= 26 && + !TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && + Utils.isOwnAccountType(getContext(), accountType) && + !mAccountCache.get().contains(new Account(accountName, accountType))) + { + // store the fact that we have an unknown account in this transaction + mStaleListCreated.set(true); + Log.d(TAG, String.format("List with unknown account %s inserted.", new Account(accountName, accountType))); + } + break; + } + case TASKS: + final TaskAdapter task = new ContentValuesTaskAdapter(values); + + mTaskProcessorChain.insert(db, task, isSyncAdapter); + + mChanged.set(true); + + rowId = task.id(); + result_uri = TaskContract.Tasks.getContentUri(mAuthority); + + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + + break; + + // inserting instances is currently disabled because we only expand one instance, + // so even though a new task (exception) would be created, no instance might show up + // we need to resolve this discrepancy. Until then this feature remains disabled. +// case INSTANCES: +// { +// InstanceAdapter instance = mInstanceProcessorChain.insert(db, new ContentValuesInstanceAdapter(values), isSyncAdapter); +// rowId = instance.id(); +// result_uri = TaskContract.Instances.getContentUri(mAuthority); +// +// postNotifyUri(Instances.getContentUri(mAuthority)); +// postNotifyUri(Tasks.getContentUri(mAuthority)); +// +// break; +// } + case PROPERTIES: + String mimetype = values.getAsString(Properties.MIMETYPE); + + if (mimetype == null) + { + throw new IllegalArgumentException("missing mimetype in property values"); + } + + Long taskId = values.getAsLong(Properties.TASK_ID); + if (taskId == null) + { + throw new IllegalArgumentException("missing task id in property values"); + } + + if (values.containsKey(Properties.PROPERTY_ID)) + { + throw new IllegalArgumentException("property id can not be written"); + } + + PropertyHandler handler = PropertyHandlerFactory.get(mimetype); + rowId = handler.insert(db, taskId, values, isSyncAdapter); + result_uri = TaskContract.Properties.getContentUri(mAuthority); + if (rowId >= 0) + { + postNotifyUri(Tasks.getContentUri(mAuthority)); + postNotifyUri(Instances.getContentUri(mAuthority)); + } + break; + + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + if (rowId > 0 && result_uri != null) + { + result_uri = ContentUris.withAppendedId(result_uri, rowId); + postNotifyUri(result_uri); + postNotifyUri(uri); + return result_uri; + } + throw new SQLException("Failed to insert row into " + uri); + } + + + @Override + public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentValues values, String selection, String[] selectionArgs, + final boolean isSyncAdapter) + { + int count = 0; + boolean dataChanged = false; + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE_ID: + // the id is ignored, we only match by account type and name given in the Uri + case SYNCSTATE: + { + if (!isSyncAdapter) + { + throw new IllegalAccessError("only sync adapters may access syncstate"); + } + + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + + if (values.size() == 0) + { + // we're done + break; + } + + values.put(SyncState.ACCOUNT_NAME, accountName); + values.put(SyncState.ACCOUNT_TYPE, accountType); + + long id = db.replace(Tables.SYNCSTATE, null, values); + if (id >= 0) + { + count = 1; + } + break; + } + case LIST_ID: + // update selection and fall through + selection = updateSelection(selectId(uri), selection); + + case LISTS: + { + // iterate over all task lists that match the selection + final Cursor cursor = db.query(Tables.LISTS, null, selection, selectionArgs, null, null, null, null); + + int idCol = cursor.getColumnIndex(TaskContract.TaskLists._ID); + + try + { + while (cursor.moveToNext()) + { + final long listId = cursor.getLong(idCol); + + // clone list values if we have more than one list to update + // we need this, because the processors may change the values + final ListAdapter list = new CursorContentValuesListAdapter(listId, cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); + + if (list.hasUpdates()) + { + mListProcessorChain.update(db, list, isSyncAdapter); + dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); + } + // note we still count the row even if no update was necessary + count++; + } + } + finally + { + cursor.close(); + } + break; + } + case TASK_ID: + // update selection and fall through + selection = updateSelection(selectId(uri), selection); + + case TASKS: + { + // iterate over all tasks that match the selection + final Cursor cursor = db.query(Tables.TASKS_VIEW, null, selection, selectionArgs, null, null, null, null); + + try + { + while (cursor.moveToNext()) + { + // clone task values if we have more than one task to update + // we need this, because the processors may change the values + final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); + + if (task.hasUpdates()) + { + mTaskProcessorChain.update(db, task, isSyncAdapter); + dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); + } + // note we still count the row even if no update was necessary + count++; + } + } + finally + { + cursor.close(); + } + + if (dataChanged) + { + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + } + break; + } + + case INSTANCE_ID: + // update selection and fall through + selection = updateSelection(selectId(uri), selection); + + case INSTANCES: + { + // iterate over all instances that match the selection + + try (Cursor cursor = db.query(Tables.INSTANCE_VIEW, null, selection, selectionArgs, null, null, null, null)) + { + while (cursor.moveToNext()) + { + // clone task values if we have more than one task to update + // we need this, because the processors may change the values + final InstanceAdapter instance = new CursorContentValuesInstanceAdapter(cursor, + cursor.getCount() > 1 ? new ContentValues(values) : values); + + if (instance.hasUpdates()) + { + mInstanceProcessorChain.update(db, instance, isSyncAdapter); + dataChanged = true; + } + // note we still count the row even if no update was necessary + count++; + } + } + + if (dataChanged) + { + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + } + break; + } + case PROPERTY_ID: + selection = updateSelection(selectPropertyId(uri), selection); + + case PROPERTIES: + if (values.containsKey(Properties.MIMETYPE)) + { + throw new IllegalArgumentException("property mimetypes can not be modified"); + } + + if (values.containsKey(Properties.TASK_ID)) + { + throw new IllegalArgumentException("task id can not be changed"); + } + + if (values.containsKey(Properties.PROPERTY_ID)) + { + throw new IllegalArgumentException("property id can not be changed"); + } + + // fetch all properties that match the selection + Cursor cursor = db.query(Tables.PROPERTIES, null, selection, selectionArgs, null, null, null); + + try + { + int propIdCol = cursor.getColumnIndex(Properties.PROPERTY_ID); + int taskIdCol = cursor.getColumnIndex(Properties.TASK_ID); + int mimeTypeCol = cursor.getColumnIndex(Properties.MIMETYPE); + while (cursor.moveToNext()) + { + long propertyId = cursor.getLong(propIdCol); + long taskId = cursor.getLong(taskIdCol); + String mimeType = cursor.getString(mimeTypeCol); + if (mimeType != null) + { + PropertyHandler handler = PropertyHandlerFactory.get(mimeType); + count += handler.update(db, taskId, propertyId, values, cursor, isSyncAdapter); + } + } + } + finally + { + cursor.close(); + } + postNotifyUri(Properties.getContentUri(mAuthority)); + break; + + case CATEGORY_ID: + String newCategorySelection = updateSelection(selectId(uri), selection); + validateCategoryValues(values, false, isSyncAdapter); + count = db.update(Tables.CATEGORIES, values, newCategorySelection, selectionArgs); + break; + case ALARM_ID: + String newAlarmSelection = updateSelection(selectId(uri), selection); + validateAlarmValues(values, false, isSyncAdapter); + count = db.update(Tables.ALARMS, values, newAlarmSelection, selectionArgs); + break; + default: + ContentOperation operation = ContentOperation.get(mUriMatcher.match(uri), OPERATIONS); + + if (operation == null) + { + throw new IllegalArgumentException("Unknown URI " + uri); + } + + operation.run(getContext(), mAsyncHandler, uri, db, values); + } + + if (dataChanged) + { + // send notifications, because non-sync columns have been updated + postNotifyUri(uri); + mChanged.set(true); + } + + return count; + } + + + /** + * Update task due and task start notifications. + */ + private void updateNotifications() + { + mAsyncHandler.post(new Runnable() + { + + @Override + public void run() + { + ContentOperation.UPDATE_NOTIFICATION_ALARM.fire(getContext(), null); + } + }); + } + + + /** + * Validate the given category values. + * + * @param values + * The category properties to validate. + * + * @throws IllegalArgumentException + * if any of the values is invalid. + */ + private void validateCategoryValues(ContentValues values, boolean isNew, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (values.containsKey(Categories._ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (isNew != values.containsKey(Categories.ACCOUNT_NAME) && (!isNew || values.get(Categories.ACCOUNT_NAME) != null)) + { + throw new IllegalArgumentException("ACCOUNT_NAME is write-once and required on INSERT"); + } + + if (isNew != values.containsKey(Categories.ACCOUNT_TYPE) && (!isNew || values.get(Categories.ACCOUNT_TYPE) != null)) + { + throw new IllegalArgumentException("ACCOUNT_TYPE is write-once and required on INSERT"); + } + } + + + /** + * Validate the given alarm values. + * + * @param values + * The alarm values to validate + * + * @throws IllegalArgumentException + * if any of the values is invalid. + */ + private void validateAlarmValues(ContentValues values, boolean isNew, boolean isSyncAdapter) + { + if (values.containsKey(Alarms.ALARM_ID)) + { + throw new IllegalArgumentException("ALARM_ID can not be set manually"); + } + } + + + @Override + public String getType(Uri uri) + { + switch (mUriMatcher.match(uri)) + { + case LISTS: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + TaskLists.CONTENT_URI_PATH; + case LIST_ID: + return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + TaskLists.CONTENT_URI_PATH; + case TASKS: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + Tasks.CONTENT_URI_PATH; + case TASK_ID: + return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + Tasks.CONTENT_URI_PATH; + case INSTANCES: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + Instances.CONTENT_URI_PATH; + case INSTANCE_ID: + return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + Instances.CONTENT_URI_PATH; + default: + throw new IllegalArgumentException("Unsupported URI: " + uri); + } + } + + + @Override + protected void onEndTransaction(boolean callerIsSyncAdapter) + { + super.onEndTransaction(callerIsSyncAdapter); + if (mChanged.compareAndSet(true, false)) + { + updateNotifications(); + Utils.sendActionProviderChangedBroadCast(getContext(), mAuthority); + } + + if (Boolean.TRUE.equals(mStaleListCreated.get())) + { + // notify UI about the stale lists, it's up the UI to deal with this, either by showing a notification or an instant popup. + Intent visbilityRequest = new Intent("org.dmfs.tasks.action.STALE_LIST_BROADCAST").setPackage(getContext().getPackageName()); + getContext().sendBroadcast(visbilityRequest); + } + } + + + @Override + public SQLiteOpenHelper getDatabaseHelper(Context context) + { + TaskDatabaseHelper helper = new TaskDatabaseHelper(context, this); + + return helper; + } + + + @Override + public void onDatabaseCreated(SQLiteDatabase db) + { + // notify listeners that the database has been created + Intent dbInitializedIntent = new Intent(TaskContract.ACTION_DATABASE_INITIALIZED); + dbInitializedIntent.setDataAndType(TaskContract.getContentUri(mAuthority), TaskContract.MIMETYPE_AUTHORITY); + // Android SDK 26 doesn't allow us to send implicit broadcasts, this particular brodcast is only for internal use, so just make it explicit by setting our package name + dbInitializedIntent.setPackage(getContext().getPackageName()); + getContext().sendBroadcast(dbInitializedIntent); + } + + + @Override + public void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion) + { + if (oldVersion < 15) + { + mAsyncHandler.post(() -> ContentOperation.UPDATE_TIMEZONE.fire(getContext(), null)); + } + } + + + @Override + protected boolean syncToNetwork(Uri uri) + { + return true; + } + + + @Override + public void onAccountsUpdated(Account[] accounts) + { + // cache the known accounts so we can check whether we know accounts for which new lists are added + mAccountCache.set(new HashSet<>(Arrays.asList(accounts))); + // TODO: we probably can move the cleanup code here and get rid of the Utils class + Utils.cleanUpLists(getContext(), getDatabaseHelper().getWritableDatabase(), accounts, mAuthority); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java new file mode 100644 index 0000000..e9b6a07 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java @@ -0,0 +1,134 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * A receiver for all task provider related broadcasts. This receiver merely forwards all incoming broadcasts to the provider, so they can be handled + * asynchronously in the provider context. + * + * @author Marten Gajda + */ +public class TaskProviderBroadcastReceiver extends BroadcastReceiver +{ + private final static int REQUEST_CODE_ALARM = 1337; + + // AGENDULA CHANGE: renamed into our namespace. Only ever used for a PendingIntent we address to + // this very class, so it collides with nothing — but a device may have OpenTasks installed too, + // and identical action strings across two apps are the kind of thing that is very confusing to + // read in a bug report. + private final static String ACTION_NOTIFICATION_ALARM = "de.jeanlucmakiola.agendula.provider.NOTIFICATION_ALARM"; + + + /** + * Registers a system alarm to update notifications at a specific time. + * + * @param context + * A Context. + * @param updateTime + * When to fire the alarm. + */ + // AGENDULA CHANGE: MissingPermission added. Lint cannot see that the setExact call below is already + // guarded by canScheduleExactAlarms(), with a set() fallback when it returns false. + @SuppressLint({ "NewApi", "MissingPermission" }) + static void planNotificationUpdate(Context context, DateTime updateTime) + { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent alarmIntent = new Intent(context, TaskProviderBroadcastReceiver.class); + alarmIntent.setAction(ACTION_NOTIFICATION_ALARM); + + // AGENDULA CHANGE: FLAG_IMMUTABLE added. Since Android 12 a PendingIntent must state its + // mutability, and this call throws IllegalArgumentException without it once targetSdk >= 31. + // Upstream targets 29, so it never hit this; we target 36. Nothing fills the intent in later, + // so immutable is also the correct choice on the merits. + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, REQUEST_CODE_ALARM, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + // cancel any previous alarm + am.cancel(pendingIntent); + + if (updateTime.isFloating()) + { + // convert floating times to absolute times + updateTime = new DateTime(TimeZone.getDefault(), updateTime.getYear(), updateTime.getMonth(), updateTime.getDayOfMonth(), updateTime.getHours(), + updateTime.getMinutes(), updateTime.getSeconds()); + } + + // AlarmManager API changed in v19 (KitKat) and the "set" method is not called at the exact time anymore + // + // AGENDULA CHANGE: fall back to an inexact alarm when exact ones aren't permitted. On API + // 31-32 SCHEDULE_EXACT_ALARM is revocable, and setExact then throws SecurityException — here, + // inside a receiver handling a system broadcast, which means the app dies every time the + // timezone changes. This alarm only re-runs the provider's own bookkeeping, so a few minutes + // of drift costs nothing; Agendula's user-visible due reminders are armed by ReminderScheduler, + // which asks for the permission properly. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()) + { + am.setExact(AlarmManager.RTC_WAKEUP, updateTime.getTimestamp(), pendingIntent); + } + else + { + am.set(AlarmManager.RTC_WAKEUP, updateTime.getTimestamp(), pendingIntent); + } + } + + + @Override + public void onReceive(Context context, Intent intent) + { + String action = intent.getAction(); + if (action == null) + { + return; + } + + // AGENDULA CHANGE: the cases below are upstream's, written out rather than reached by + // fall-through. Upstream's switch has no `break` anywhere, so TIMEZONE_CHANGED already runs + // all three operations and ACTION_NOTIFICATION_ALARM runs the last two — which is what this + // does, unchanged. It is spelled out because the comment upstream attaches to the first case + // ("don't trigger the notifications update yet") describes breaks that were never written, + // so the code and the stated intent disagree and only one of them can be preserved. + // + // Behaviour wins: a vendored fork is the wrong place to act on a guess. Whether the missing + // breaks are the bug, or the comment is, wants a device with a task due across a timezone + // change to settle — see provider/PROVENANCE.md. + if (Intent.ACTION_TIMEZONE_CHANGED.equals(action)) + { + // the local timezone has been changed, notify the provider to take the necessary steps. + ContentOperation.UPDATE_TIMEZONE.fire(context, null); + } + if (Intent.ACTION_TIMEZONE_CHANGED.equals(action) || ACTION_NOTIFICATION_ALARM.equals(action)) + { + // it's time for the next notification + ContentOperation.POST_NOTIFICATIONS.fire(context, null); + } + // at this time all other actions trigger an update of the notification alarm + ContentOperation.UPDATE_NOTIFICATION_ALARM.fire(context, null); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/Utils.java b/provider/src/main/java/org/dmfs/provider/tasks/Utils.java new file mode 100644 index 0000000..febcd52 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/Utils.java @@ -0,0 +1,214 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorDescription; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.procedure.composite.Batch; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.provider.tasks.utils.ResourceArray; +import org.dmfs.provider.tasks.utils.With; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.SyncState; +import org.dmfs.tasks.contract.TaskContract.TaskListColumns; +import org.dmfs.tasks.contract.TaskContract.TaskListSyncColumns; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; +import de.jeanlucmakiola.agendula.provider.R; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + + +/** + * The Class Utils. + * + * @author Tobias Reinsch + * @author Marten Gajda + */ +public class Utils +{ + private static final AtomicReference> sOwnAccountTypes = new AtomicReference<>(null); + + + public static void sendActionProviderChangedBroadCast(Context context, String authority) + { + // TODO: Using the TaskContract content uri results in a "Unknown URI content" error message. Using the Tasks content uri instead will break the + // broadcast receiver. We have to find away around this + // TODO: coalesce fast consecutive broadcasts, a delay of up to 1 second should be acceptable + + new With<>(new Intent(Intent.ACTION_PROVIDER_CHANGED, TaskContract.getContentUri(authority))) + .process(providerChangedIntent -> + new Batch(context::sendBroadcast) + .process(new Mapped<>( + packageName -> new Intent(providerChangedIntent).setPackage(packageName), + // TODO: fow now we hard code 3rd party package names, this should be replaced by some sort or registry + // see https://github.com/dmfs/opentasks/issues/824 + new Joined<>( + new SingletonIterable<>(context.getPackageName()), + new ResourceArray(context, R.array.agendula_provider_changed_receivers))))); + } + + + /** + * The account types this package owns an authenticator for. + *

+ * AGENDULA CHANGE. Upstream held {@code android.permission.GET_ACCOUNTS} and so could enumerate every account on the device; we dropped it, because + * since API 26 an authenticator already makes its own accounts visible to its own package and nothing else concerns us. + *

+ * That deletion is only safe together with {@link #cleanUpLists}: an account we cannot see is indistinguishable from an account that has been removed, + * and the upstream cleanup treats the latter as a licence to delete the lists hanging off it. Restricting the cleanup to types in this set makes that + * failure impossible by construction rather than by care — the provider can only ever prune accounts it is authoritative about. + *

+ * While Agendula ships no sync adapter this set is empty and no list is ever pruned. Our own account type joins it automatically the moment the + * authenticator is declared, with no further change here. + * + * @return the account types authenticated by this very package, possibly empty — never null. + */ + static Set ownAccountTypes(Context context) + { + Set cached = sOwnAccountTypes.get(); + if (cached != null) + { + return cached; + } + String ownPackage = context.getPackageName(); + Set types = new HashSet<>(); + for (AuthenticatorDescription description : AccountManager.get(context).getAuthenticatorTypes()) + { + if (ownPackage.equals(description.packageName)) + { + types.add(description.type); + } + } + // Which authenticators our own package declares is fixed at install time, so this cannot go + // stale within a process. Worth caching: isOwnAccountType runs on every task-list insert and + // getAuthenticatorTypes is a binder round trip. + Set result = Collections.unmodifiableSet(types); + sOwnAccountTypes.compareAndSet(null, result); + return sOwnAccountTypes.get(); + } + + + /** + * Whether {@code accountType} is authenticated by this package. See {@link #ownAccountTypes}. + */ + static boolean isOwnAccountType(Context context, String accountType) + { + return ownAccountTypes(context).contains(accountType); + } + + + /** + * Drops the {@link #ownAccountTypes} cache. Tests only — in a real process the answer is fixed at install time, which is the entire reason it is cached. + */ + static void clearOwnAccountTypesCache() + { + sOwnAccountTypes.set(null); + } + + + public static void cleanUpLists(Context context, SQLiteDatabase db, Account[] accounts, String authority) + { + // make a list of the accounts array + List accountList = Arrays.asList(accounts); + // AGENDULA CHANGE — see ownAccountTypes: only types we authenticate ourselves may be pruned. + Set prunableTypes = ownAccountTypes(context); + + db.beginTransaction(); + + try + { + Cursor c = db.query(Tables.LISTS, new String[] { TaskListColumns._ID, TaskListSyncColumns.ACCOUNT_NAME, TaskListSyncColumns.ACCOUNT_TYPE }, null, + null, null, null, null); + + // build a list of all task list ids that no longer have an account + List obsoleteLists = new ArrayList(); + try + { + while (c.moveToNext()) + { + String accountType = c.getString(2); + // mark list for removal if it is non-local, of a type we authenticate + // ourselves, and the account is not in accountList + if (!TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && prunableTypes.contains(accountType)) + { + Account account = new Account(c.getString(1), accountType); + if (!accountList.contains(account)) + { + obsoleteLists.add(c.getLong(0)); + + // remove syncstate for this account right away + db.delete(Tables.SYNCSTATE, SyncState.ACCOUNT_NAME + "=? and " + SyncState.ACCOUNT_TYPE + "=?", new String[] { + account.name, + account.type }); + } + } + } + } + finally + { + c.close(); + } + + if (obsoleteLists.size() == 0) + { + // nothing to do here + return; + } + + // remove all accounts in the list + for (Long id : obsoleteLists) + { + if (id != null) + { + db.delete(Tables.LISTS, TaskListColumns._ID + "=" + id, null); + } + } + db.setTransactionSuccessful(); + } + finally + { + db.endTransaction(); + } + // notify all observers + + ContentResolver cr = context.getContentResolver(); + cr.notifyChange(TaskLists.getContentUri(authority), null); + cr.notifyChange(Tasks.getContentUri(authority), null); + cr.notifyChange(Instances.getContentUri(authority), null); + + Utils.sendActionProviderChangedBroadCast(context, authority); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java new file mode 100644 index 0000000..4882d21 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java @@ -0,0 +1,133 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.tasks.contract.TaskContract.Property; + + +/** + * This class is used to handle alarm property values during database transactions. + * + * @author Tobias Reinsch + */ +public class AlarmHandler extends PropertyHandler +{ + + // private static final String[] ALARM_ID_PROJECTION = { Alarms.ALARM_ID }; + // private static final String ALARM_SELECTION = Alarms.ALARM_ID + " =?"; + + + /** + * Validates the content of the alarm prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property if isNew is false. If isNew is true this value is ignored. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (values.containsKey(Property.Alarm.PROPERTY_ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (!values.containsKey(Property.Alarm.MINUTES_BEFORE)) + { + throw new IllegalArgumentException("alarm property requires a time offset"); + } + + if (!values.containsKey(Property.Alarm.REFERENCE) || values.getAsInteger(Property.Alarm.REFERENCE) < 0) + { + throw new IllegalArgumentException("alarm property requires a valid reference date "); + } + + if (!values.containsKey(Property.Alarm.ALARM_TYPE)) + { + throw new IllegalArgumentException("alarm property requires an alarm type"); + } + + return values; + } + + + /** + * Inserts the alarm into the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task the new property belongs to. + * @param values + * The {@link ContentValues} to insert. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The row id of the new alarm as long + */ + @Override + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + values = validateValues(db, taskId, -1, true, values, isSyncAdapter); + return super.insert(db, taskId, values, isSyncAdapter); + } + + + /** + * Updates the alarm in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param values + * The {@link ContentValues} to update. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The number of rows affected. + */ + @Override + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + values = validateValues(db, taskId, propertyId, false, values, isSyncAdapter); + return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java new file mode 100644 index 0000000..8c129cb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java @@ -0,0 +1,277 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper.CategoriesMapping; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.tasks.contract.TaskContract.Categories; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.Property.Category; +import org.dmfs.tasks.contract.TaskContract.Tasks; + + +/** + * This class is used to handle category property values during database transactions. + * + * @author Tobias Reinsch + */ +public class CategoryHandler extends PropertyHandler +{ + + private static final String[] CATEGORY_ID_PROJECTION = { Categories._ID, Categories.NAME, Categories.COLOR }; + + private static final String CATEGORY_ID_SELECTION = Categories._ID + "=? and " + Categories.ACCOUNT_NAME + "=? and " + Categories.ACCOUNT_TYPE + "=?"; + private static final String CATEGORY_NAME_SELECTION = Categories.NAME + "=? and " + Categories.ACCOUNT_NAME + "=? and " + Categories.ACCOUNT_TYPE + "=?"; + + public static final String IS_NEW_CATEGORY = "is_new_category"; + + + /** + * Validates the content of the category prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property if isNew is false. If isNew is true this value is ignored. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + // the category requires a name or an id + if (!values.containsKey(Category.CATEGORY_ID) && !values.containsKey(Category.CATEGORY_NAME)) + { + throw new IllegalArgumentException("Neiter an id nor a category name was supplied for the category property."); + } + + // get the matching task & account for the property + if (!values.containsKey(Properties.TASK_ID)) + { + throw new IllegalArgumentException("No task id was supplied for the category property"); + } + String[] queryArgs = { values.getAsString(Properties.TASK_ID) }; + String[] queryProjection = { Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE }; + String querySelection = Tasks._ID + "=?"; + Cursor taskCursor = db.query(Tables.TASKS_VIEW, queryProjection, querySelection, queryArgs, null, null, null); + + String accountName = null; + String accountType = null; + try + { + if (taskCursor.moveToNext()) + { + accountName = taskCursor.getString(0); + accountType = taskCursor.getString(1); + + values.put(Categories.ACCOUNT_NAME, accountName); + values.put(Categories.ACCOUNT_TYPE, accountType); + } + } + finally + { + if (taskCursor != null) + { + taskCursor.close(); + } + } + + if (accountName != null && accountType != null) + { + // search for matching categories + String[] categoryArgs; + Cursor cursor; + + if (values.containsKey(Categories._ID)) + { + // serach by ID + categoryArgs = new String[] { values.getAsString(Category.CATEGORY_ID), accountName, accountType }; + cursor = db.query(Tables.CATEGORIES, CATEGORY_ID_PROJECTION, CATEGORY_ID_SELECTION, categoryArgs, null, null, null); + } + else + { + // search by name + categoryArgs = new String[] { values.getAsString(Category.CATEGORY_NAME), accountName, accountType }; + cursor = db.query(Tables.CATEGORIES, CATEGORY_ID_PROJECTION, CATEGORY_NAME_SELECTION, categoryArgs, null, null, null); + } + try + { + if (cursor != null && cursor.getCount() == 1) + { + cursor.moveToNext(); + Long categoryID = cursor.getLong(0); + String categoryName = cursor.getString(1); + int color = cursor.getInt(2); + + values.put(Category.CATEGORY_ID, categoryID); + values.put(Category.CATEGORY_NAME, categoryName); + values.put(Category.CATEGORY_COLOR, color); + values.put(IS_NEW_CATEGORY, false); + } + else + { + values.put(IS_NEW_CATEGORY, true); + } + } + finally + { + if (cursor != null) + { + cursor.close(); + } + } + + } + + return values; + } + + + /** + * Inserts the category into the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task the new property belongs to. + * @param values + * The {@link ContentValues} to insert. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The row id of the new category as long + */ + @Override + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + values = validateValues(db, taskId, -1, true, values, isSyncAdapter); + values = getOrInsertCategory(db, values); + + // insert property row and create relation + long id = super.insert(db, taskId, values, isSyncAdapter); + insertRelation(db, taskId, values.getAsLong(Category.CATEGORY_ID), id); + + // update FTS entry with category name + updateFTSEntry(db, taskId, id, values.getAsString(Category.CATEGORY_NAME)); + return id; + } + + + /** + * Updates the category in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param values + * The {@link ContentValues} to update. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The number of rows affected. + */ + @Override + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + values = validateValues(db, taskId, propertyId, false, values, isSyncAdapter); + values = getOrInsertCategory(db, values); + + if (values.containsKey(Category.CATEGORY_NAME)) + { + // update FTS entry with new category name + updateFTSEntry(db, taskId, propertyId, values.getAsString(Category.CATEGORY_NAME)); + } + + return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); + } + + + /** + * Check if a category with matching {@link ContentValues} exists and returns the existing category or creates a new category in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param values + * The {@link ContentValues} of the category. + * + * @return The {@link ContentValues} of the existing or new category. + */ + private ContentValues getOrInsertCategory(SQLiteDatabase db, ContentValues values) + { + if (values.getAsBoolean(IS_NEW_CATEGORY)) + { + // insert new category in category table + ContentValues newCategoryValues = new ContentValues(4); + newCategoryValues.put(Categories.ACCOUNT_NAME, values.getAsString(Categories.ACCOUNT_NAME)); + newCategoryValues.put(Categories.ACCOUNT_TYPE, values.getAsString(Categories.ACCOUNT_TYPE)); + newCategoryValues.put(Categories.NAME, values.getAsString(Category.CATEGORY_NAME)); + newCategoryValues.put(Categories.COLOR, values.getAsInteger(Category.CATEGORY_COLOR)); + + long categoryID = db.insert(Tables.CATEGORIES, "", newCategoryValues); + values.put(Category.CATEGORY_ID, categoryID); + } + + // remove redundant values + values.remove(IS_NEW_CATEGORY); + values.remove(Categories.ACCOUNT_NAME); + values.remove(Categories.ACCOUNT_TYPE); + + return values; + } + + + /** + * Inserts a relation entry in the database to link task and category. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The row id of the task. + * @param categoryId + * The row id of the category. + * + * @return The row id of the inserted relation. + */ + private long insertRelation(SQLiteDatabase db, long taskId, long categoryId, long propertyId) + { + ContentValues relationValues = new ContentValues(3); + relationValues.put(CategoriesMapping.TASK_ID, taskId); + relationValues.put(CategoriesMapping.CATEGORY_ID, categoryId); + relationValues.put(CategoriesMapping.PROPERTY_ID, propertyId); + return db.insert(Tables.CATEGORIES_MAPPING, "", relationValues); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java new file mode 100644 index 0000000..52d32d3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + + +/** + * This class is used to handle properties with unknown / unsupported mime-types. + * + * @author Tobias Reinsch + */ +public class DefaultPropertyHandler extends PropertyHandler +{ + + /** + * Validates the content of the alarm prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + return values; + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java new file mode 100644 index 0000000..a01e39d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java @@ -0,0 +1,155 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.FTSDatabaseHelper; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.tasks.contract.TaskContract.Properties; + + +/** + * Abstract class that is used as template for specific property handlers. + * + * @author Tobias Reinsch + */ +public abstract class PropertyHandler +{ + + /** + * Validates the content of the property prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property if isNew is false. If isNew is true this value is ignored. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + public abstract ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter); + + + /** + * Inserts the property {@link ContentValues} into the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task the new property belongs to. + * @param values + * The {@link ContentValues} to insert. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The row id of the new property as long + */ + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + return db.insert(Tables.PROPERTIES, "", values); + } + + + /** + * Updates the property {@link ContentValues} in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param values + * The {@link ContentValues} to update. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The number of rows affected. + */ + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + return db.update(Tables.PROPERTIES, values, Properties.PROPERTY_ID + "=" + propertyId, null); + } + + + /** + * Deletes the property in the database. + * + * @param db + * The belonging database. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return + */ + public int delete(SQLiteDatabase db, long taskId, long propertyId, Cursor oldValues, boolean isSyncAdapter) + { + return db.delete(Tables.PROPERTIES, Properties.PROPERTY_ID + "=" + propertyId, null); + + } + + + /** + * Method hook to insert FTS entries on database migration. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * the row id of the task this property belongs to + * @param propertyId + * the id of the property + * @param text + * the searchable text of the property. If the property has multiple text snippets to search in, concat them separated by a space. + */ + protected void updateFTSEntry(SQLiteDatabase db, long taskId, long propertyId, String text) + { + FTSDatabaseHelper.updatePropertyFTSEntry(db, taskId, propertyId, text); + } + + + public ContentValues cloneForNewTask(long newTaskId, ContentValues values) + { + ContentValues newValues = new ContentValues(values); + newValues.remove(Properties.PROPERTY_ID); + newValues.put(Properties.TASK_ID, newTaskId); + return newValues; + } + + + ; +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java new file mode 100644 index 0000000..0e19463 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java @@ -0,0 +1,61 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import org.dmfs.tasks.contract.TaskContract.Property.Alarm; +import org.dmfs.tasks.contract.TaskContract.Property.Category; +import org.dmfs.tasks.contract.TaskContract.Property.Relation; + + +/** + * A factory that creates the matching {@link PropertyHandler} for the given mimetype. + * + * @author Tobias Reinsch + */ +public class PropertyHandlerFactory +{ + private final static PropertyHandler CATEGORY_HANDLER = new CategoryHandler(); + private final static PropertyHandler ALARM_HANDLER = new AlarmHandler(); + private final static PropertyHandler RELATION_HANDLER = new RelationHandler(); + private final static PropertyHandler DEFAULT_PROPERTY_HANDLER = new DefaultPropertyHandler(); + + + /** + * Creates a specific {@link PropertyHandler}. + * + * @param mimeType + * The mimetype of the property. + * + * @return The matching {@link PropertyHandler} for the given mimetype or null + */ + public static PropertyHandler get(String mimeType) + { + if (Category.CONTENT_ITEM_TYPE.equals(mimeType)) + { + return CATEGORY_HANDLER; + } + if (Alarm.CONTENT_ITEM_TYPE.equals(mimeType)) + { + return ALARM_HANDLER; + } + if (Relation.CONTENT_ITEM_TYPE.equals(mimeType)) + { + return RELATION_HANDLER; + } + return DEFAULT_PROPERTY_HANDLER; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java new file mode 100644 index 0000000..8f896c5 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java @@ -0,0 +1,276 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.tasks.contract.TaskContract.Property.Relation; +import org.dmfs.tasks.contract.TaskContract.Tasks; + + +/** + * Handles any inserts, updates and deletes on the relations table. + * + * @author Marten Gajda + */ +public class RelationHandler extends PropertyHandler +{ + + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + if (values.containsKey(Relation.RELATED_CONTENT_URI)) + { + throw new IllegalArgumentException("setting of RELATED_CONTENT_URI not allowed"); + } + + Long id = values.getAsLong(Relation.RELATED_ID); + String uid = values.getAsString(Relation.RELATED_UID); + + if (id == null && uid != null) + { + values.putNull(Relation.RELATED_ID); + } + else if (id != null && uid == null) + { + values.putNull(Relation.RELATED_UID); + } + else + { + throw new IllegalArgumentException("exactly one of RELATED_ID, RELATED_UID and RELATED_URI must be non-null"); + } + + return values; + } + + + @Override + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + validateValues(db, taskId, -1, true, values, isSyncAdapter); + resolveFields(db, values); + updateParentId(db, taskId, values, null); + return super.insert(db, taskId, values, isSyncAdapter); + } + + + @Override + public ContentValues cloneForNewTask(long newTaskId, ContentValues values) + { + ContentValues newValues = super.cloneForNewTask(newTaskId, values); + newValues.remove(Relation.RELATED_CONTENT_URI); + return newValues; + } + + + @Override + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + validateValues(db, taskId, propertyId, false, values, isSyncAdapter); + resolveFields(db, values); + updateParentId(db, taskId, values, oldValues); + return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); + } + + + @Override + public int delete(SQLiteDatabase db, long taskId, long propertyId, Cursor oldValues, boolean isSyncAdapter) + { + clearParentId(db, taskId, oldValues); + return super.delete(db, taskId, propertyId, oldValues, isSyncAdapter); + } + + + /** + * Resolve _id or _uid, depending of which value is given. + *

+ * TODO: store links into the calendar provider if we find an event that matches the UID. + *

+ * + * @param db + * The task database. + * @param values + * The {@link ContentValues}. + */ + private void resolveFields(SQLiteDatabase db, ContentValues values) + { + Long id = values.getAsLong(Relation.RELATED_ID); + String uid = values.getAsString(Relation.RELATED_UID); + + if (id != null) + { + values.put(Relation.RELATED_UID, resolveTaskStringField(db, Tasks._ID, id.toString(), Tasks._UID)); + } + else if (uid != null) + { + values.put(Relation.RELATED_ID, resolveTaskLongField(db, Tasks._UID, uid, Tasks._ID)); + } + } + + + private Long resolveTaskLongField(SQLiteDatabase db, String selectionField, String selectionValue, String resultField) + { + String result = resolveTaskStringField(db, selectionField, selectionValue, resultField); + if (result != null) + { + return Long.parseLong(result); + } + return null; + } + + + private String resolveTaskStringField(SQLiteDatabase db, String selectionField, String selectionValue, String resultField) + { + Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, new String[] { resultField }, selectionField + "=?", new String[] { selectionValue }, null, null, + null); + if (c != null) + { + try + { + if (c.moveToNext()) + { + return c.getString(0); + } + } + finally + { + c.close(); + } + } + return null; + } + + + /** + * Update {@link Tasks#PARENT_ID} when a parent is assigned to a child. + * + * @param db + * @param taskId + * @param values + * @param oldValues + */ + // AGENDULA CHANGE: lint's Range check flags getInt(getColumnIndex(...)), since getColumnIndex returns -1 for an absent column. Both callers pass a cursor + // over a property row selected with a projection that contains RELATED_TYPE, so the index is never -1 in practice. Suppressed rather than "fixed": + // inventing a fallback value would change what the provider does on a path upstream chose to let fail loudly, and this is a fork, not our design. + @SuppressLint("Range") + private void updateParentId(SQLiteDatabase db, long taskId, ContentValues values, Cursor oldValues) + { + int type; + if (values.containsKey(Relation.RELATED_TYPE)) + { + type = values.getAsInteger(Relation.RELATED_TYPE); + } + else + { + type = oldValues.getInt(oldValues.getColumnIndex(Relation.RELATED_TYPE)); + } + + if (type == Relation.RELTYPE_PARENT) + { + // this is a link to the parent, we need to update the PARENT_ID of this task, if we can + + if (values.containsKey(Relation.RELATED_ID)) + { + ContentValues taskValues = new ContentValues(1); + taskValues.put(Tasks.PARENT_ID, values.getAsLong(Relation.RELATED_ID)); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); + } + // else: the parent task is probably not synced yet, we have to fix this in RelationUpdaterHook + } + else if (type == Relation.RELTYPE_CHILD) + { + // this is a link to a child, we need to update the PARENT_ID of the linked task + + if (values.getAsLong(Relation.RELATED_ID) != null) + { + ContentValues taskValues = new ContentValues(1); + taskValues.put(Tasks.PARENT_ID, taskId); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + values.getAsLong(Relation.RELATED_ID), null); + } + // else: the child task is probably not synced yet, we have to fix this in RelationUpdaterHook + } + else if (type == Relation.RELTYPE_SIBLING) + { + // this is a link to a sibling, we need to copy the PARENT_ID of the linked task to this task + if (values.getAsLong(Relation.RELATED_ID) != null) + { + // get the parent of the other task first + Long otherParent = resolveTaskLongField(db, Tasks._ID, values.getAsString(Relation.RELATED_ID), Tasks.PARENT_ID); + + ContentValues taskValues = new ContentValues(1); + taskValues.put(Tasks.PARENT_ID, otherParent); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); + } + // else: the sibling task is probably not synced yet, we have to fix this in RelationUpdaterHook + } + } + + + /** + * Clear {@link Tasks#PARENT_ID} if a link is removed. + * + * @param db + * @param taskId + * @param oldValues + */ + // AGENDULA CHANGE: see updateParentId — same Range suppression, same reason. + @SuppressLint("Range") + private void clearParentId(SQLiteDatabase db, long taskId, Cursor oldValues) + { + int type = oldValues.getInt(oldValues.getColumnIndex(Relation.RELATED_TYPE)); + + /* + * This is more complicated than it may sound. We don't know the order in which relations are created, updated or removed. So it's possible that a new + * parent relationship has been created and the old one is removed afterwards. In that case we can not simply clear the PARENT_ID. + * + * FIXME: For now we ignore that fact. But we should fix it. + */ + + if (type == Relation.RELTYPE_PARENT) + { + // this was a link to the parent, we're orphaned now, so clear PARENT_ID of this task + + ContentValues taskValues = new ContentValues(1); + taskValues.putNull(Tasks.PARENT_ID); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); + } + else if (type == Relation.RELTYPE_CHILD) + { + // this was a link to a child, the child is orphaned now, clear its PARENT_ID + + int relIdCol = oldValues.getColumnIndex(Relation.RELATED_ID); + if (!oldValues.isNull(relIdCol)) + { + ContentValues taskValues = new ContentValues(1); + taskValues.putNull(Tasks.PARENT_ID); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + oldValues.getLong(relIdCol), null); + } + } + // else if (type == Relation.RELTYPE_SIBLING) + // { + /* + * This was a link to a sibling, since it's no longer our sibling either it or we're orphaned now We won't know unless we check all relations. + * + * FIXME: properly handle this case + */ + // } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java new file mode 100644 index 0000000..4cae1c8 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentUris; +import android.net.Uri; + +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An abstract implementation of a {@link InstanceAdapter} to server as the base for more concrete adapters. + * + * @author Marten Gajda + */ +public abstract class AbstractInstanceAdapter implements InstanceAdapter +{ + @Override + public final Uri uri(String authority) + { + return ContentUris.withAppendedId(TaskContract.Instances.getContentUri(authority), id()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java new file mode 100644 index 0000000..80478e3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.net.Uri; + +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An abstract implementation of a {@link ListAdapter} to server as the base for more concrete adapters. + * + * @author Marten Gajda + */ +public abstract class AbstractListAdapter implements ListAdapter +{ + private final ContentValues mState = new ContentValues(10); + + + @Override + public Uri uri(String authority) + { + return ContentUris.withAppendedId(TaskContract.TaskLists.getContentUri(authority), id()); + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return stateFieldAdater.getFrom(mState); + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + stateFieldAdater.setIn(mState, value); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java new file mode 100644 index 0000000..1f23f77 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.net.Uri; + +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An abstract implementation of a {@link TaskAdapter} to server as the base for more concrete adapters. + * + * @author Marten Gajda + */ +public abstract class AbstractTaskAdapter implements TaskAdapter +{ + private final ContentValues mState = new ContentValues(10); + + + @Override + public Uri uri(String authority) + { + return ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(authority), id()); + } + + + @Override + public boolean isRecurring() + { + // recurring tasks must have an RRULE or RDATEs and at least one of DTSTART and DUE date + return (valueOf(RRULE) != null || valueOf(RDATE).iterator().hasNext()) && (valueOf(DTSTART) != null || valueOf(DUE) != null); + } + + + @Override + public boolean recurrenceUpdated() + { + return isUpdated(RRULE) || isUpdated(DTSTART) || isUpdated(DUE) || isUpdated(DURATION) || isUpdated(RDATE) || isUpdated(EXDATE); + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return stateFieldAdater.getFrom(mState); + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + stateFieldAdater.setIn(mState, value); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java new file mode 100644 index 0000000..58a7e54 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java @@ -0,0 +1,161 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.jems.single.elementary.Reduced; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link TaskAdapter} for tasks that are stored in a {@link ContentValues}. + * + * @author Marten Gajda + */ +public class ContentValuesInstanceAdapter extends AbstractInstanceAdapter +{ + private long mId; + private final ContentValues mValues; + + + public ContentValuesInstanceAdapter(ContentValues values) + { + this(-1L, values); + } + + + public ContentValuesInstanceAdapter(long id, ContentValues values) + { + mId = id; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return null; + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + return fieldAdapter.isSetIn(mValues); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues.size() > 0; + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + if (mId < 0) + { + mId = db.insert(TaskDatabaseHelper.Tables.TASKS, null, mValues); + return mId > 0 ? 1 : 0; + } + else + { + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return null; + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + + } + + + @Override + public InstanceAdapter duplicate() + { + return new ContentValuesInstanceAdapter(new ContentValues(mValues)); + } + + + @Override + public TaskAdapter taskAdapter() + { + // make sure we remove any instance fields + return new ContentValuesTaskAdapter(new Reduced( + () -> new ContentValues(mValues), + (contentValues, column) -> { + contentValues.remove(column); + return contentValues; + }, + INSTANCE_COLUMN_NAMES).value()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java new file mode 100644 index 0000000..d441848 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java @@ -0,0 +1,130 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * @author Marten Gajda + */ +public class ContentValuesListAdapter extends AbstractListAdapter +{ + private long mId; + private final ContentValues mValues; + + + public ContentValuesListAdapter(ContentValues values) + { + this(-1L, values); + } + + + public ContentValuesListAdapter(long id, ContentValues values) + { + mId = id; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return null; + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + return fieldAdapter.isSetIn(mValues); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues.size() > 0; + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + if (mId < 0) + { + mId = db.insert(TaskDatabaseHelper.Tables.LISTS, null, mValues); + return mId > 0 ? 1 : 0; + } + else + { + return db.update(TaskDatabaseHelper.Tables.LISTS, mValues, TaskContract.TaskListColumns._ID + "=" + mId, null); + } + } + + + @Override + public ListAdapter duplicate() + { + return new ContentValuesListAdapter(new ContentValues(mValues)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java new file mode 100644 index 0000000..c8a3b8d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java @@ -0,0 +1,132 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link TaskAdapter} for tasks that are stored in a {@link ContentValues}. + * + * @author Marten Gajda + */ +public class ContentValuesTaskAdapter extends AbstractTaskAdapter +{ + private long mId; + private final ContentValues mValues; + + + public ContentValuesTaskAdapter(ContentValues values) + { + this(-1L, values); + } + + + public ContentValuesTaskAdapter(long id, ContentValues values) + { + mId = id; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return null; + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + return fieldAdapter.isSetIn(mValues); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues.size() > 0; + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + if (mId < 0) + { + mId = db.insert(TaskDatabaseHelper.Tables.TASKS, null, mValues); + return mId > 0 ? 1 : 0; + } + else + { + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + } + + + @Override + public TaskAdapter duplicate() + { + return new ContentValuesTaskAdapter(new ContentValues(mValues)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java new file mode 100644 index 0000000..6213cb3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java @@ -0,0 +1,212 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.single.elementary.Collected; +import org.dmfs.jems.single.elementary.Reduced; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.ArrayList; + + +/** + * An {@link InstanceAdapter} that adapts a {@link Cursor} and a {@link ContentValues} instance. All changes are written to the {@link ContentValues} and can be + * stored in the database with {@link #commit(SQLiteDatabase)}. + * + * @author Marten Gajda + */ +public class CursorContentValuesInstanceAdapter extends AbstractInstanceAdapter +{ + private final long mId; + private final Cursor mCursor; + private final ContentValues mValues; + + + public CursorContentValuesInstanceAdapter(Cursor cursor, ContentValues values) + { + if (cursor == null && !_ID.existsIn(values)) + { + mId = -1L; + } + else + { + mId = _ID.getFrom(cursor); + } + mCursor = cursor; + mValues = values; + } + + + public CursorContentValuesInstanceAdapter(long id, Cursor cursor, ContentValues values) + { + mId = id; + mCursor = cursor; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + if (mValues == null) + { + return fieldAdapter.getFrom(mCursor); + } + return fieldAdapter.getFrom(mCursor, mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor); + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + if (mValues == null || !fieldAdapter.isSetIn(mValues)) + { + return false; + } + Object oldValue = fieldAdapter.getFrom(mCursor); + Object newValue = fieldAdapter.getFrom(mValues); + + return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); + } + + + @Override + public boolean isWriteable() + { + return mValues != null; + } + + + @Override + public boolean hasUpdates() + { + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return null; + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + + } + + + @Override + public InstanceAdapter duplicate() + { + ContentValues newValues = new ContentValues(mValues); + + // copy all columns (except _ID) that are not in the values yet + for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) + { + String column = mCursor.getColumnName(i); + if (!newValues.containsKey(column) && !TaskContract.Instances._ID.equals(column)) + { + newValues.put(column, mCursor.getString(i)); + } + } + + return new ContentValuesInstanceAdapter(newValues); + } + + + @Override + public TaskAdapter taskAdapter() + { + // make sure we remove any instance fields + ContentValues values = new Reduced( + () -> new ContentValues(mValues), + (contentValues, column) -> { + contentValues.remove(column); + return contentValues; + }, + INSTANCE_COLUMN_NAMES).value(); + + // create a new cursor which doesn't contain the instance columns + String[] cursorColumns = new Collected<>( + ArrayList::new, + new Sieved<>(col -> !INSTANCE_COLUMN_NAMES.contains(col), new Seq<>(mCursor.getColumnNames()))) + .value().toArray(new String[0]); + MatrixCursor cursor = new MatrixCursor(cursorColumns); + cursor.addRow( + new Mapped<>( + column -> mCursor.getType(column) == Cursor.FIELD_TYPE_BLOB ? mCursor.getBlob(column) : mCursor.getString(column), + new Mapped<>( + mCursor::getColumnIndex, + new Seq<>(cursorColumns)))); + cursor.moveToFirst(); + return new CursorContentValuesTaskAdapter(valueOf(InstanceAdapter.TASK_ID), cursor, values); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java new file mode 100644 index 0000000..4bdffb5 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java @@ -0,0 +1,139 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * @author Marten Gajda + */ +public class CursorContentValuesListAdapter extends AbstractListAdapter +{ + private final long mId; + private final Cursor mCursor; + private final ContentValues mValues; + + + public CursorContentValuesListAdapter(long id, Cursor cursor, ContentValues values) + { + mId = id; + mCursor = cursor; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor, mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor); + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + if (mValues == null || !fieldAdapter.isSetIn(mValues)) + { + return false; + } + Object oldValue = fieldAdapter.getFrom(mCursor); + Object newValue = fieldAdapter.getFrom(mValues); + + return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + return db.update(TaskDatabaseHelper.Tables.LISTS, mValues, TaskContract.TaskListColumns._ID + "=" + mId, null); + } + + + @Override + public ListAdapter duplicate() + { + ContentValues newValues = new ContentValues(mValues); + + // copy all columns (except _ID) that are not in the values yet + for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) + { + String column = mCursor.getColumnName(i); + if (!newValues.containsKey(column) && !TaskContract.Tasks._ID.equals(column)) + { + newValues.put(column, mCursor.getString(i)); + } + } + + return new ContentValuesListAdapter(newValues); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java new file mode 100644 index 0000000..0ed20df --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java @@ -0,0 +1,169 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link TaskAdapter} that adapts a {@link Cursor} and a {@link ContentValues} instance. All changes are written to the {@link ContentValues} and can be + * stored in the database with {@link #commit(SQLiteDatabase)}. + * + * @author Marten Gajda + */ +public class CursorContentValuesTaskAdapter extends AbstractTaskAdapter +{ + private final long mId; + private final Cursor mCursor; + private final ContentValues mValues; + + + public CursorContentValuesTaskAdapter(Cursor cursor, ContentValues values) + { + if (cursor == null && !_ID.existsIn(values)) + { + mId = -1L; + } + else + { + mId = _ID.getFrom(cursor); + } + mCursor = cursor; + mValues = values; + } + + + public CursorContentValuesTaskAdapter(long id, Cursor cursor, ContentValues values) + { + mId = id; + mCursor = cursor; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + if (mValues == null) + { + return fieldAdapter.getFrom(mCursor); + } + return fieldAdapter.getFrom(mCursor, mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor); + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + if (mValues == null || !fieldAdapter.isSetIn(mValues)) + { + return false; + } + Object oldValue = fieldAdapter.existsIn(mCursor) ? fieldAdapter.getFrom(mCursor) : null; + Object newValue = fieldAdapter.getFrom(mValues); + // we need to special case RRULE, because RecurrenceRule doesn't support `equals` + if (fieldAdapter != TaskAdapter.RRULE) + { + return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); + } + else + { + // in case of RRULE we compare the String values. + return oldValue == null && newValue != null || oldValue != null && (newValue == null || !oldValue.toString().equals(newValue.toString())); + } + } + + + @Override + public boolean isWriteable() + { + return mValues != null; + } + + + @Override + public boolean hasUpdates() + { + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + + + @Override + public TaskAdapter duplicate() + { + ContentValues newValues = new ContentValues(mValues); + + // copy all columns (except _ID) that are not in the values yet + for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) + { + String column = mCursor.getColumnName(i); + if (!newValues.containsKey(column) && !TaskContract.Tasks._ID.equals(column)) + { + newValues.put(column, mCursor.getString(i)); + } + } + + return new ContentValuesTaskAdapter(newValues); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java new file mode 100644 index 0000000..b3d6c7e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java @@ -0,0 +1,151 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; + + +/** + * Adapter to read values of a specific entity type from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface EntityAdapter +{ + /** + * Returns the row id of the entity or -1 if the entity has not been stored yet. + * + * @return The entity row id or -1. + */ + long id(); + + /** + * Returns the {@link Uri} of the entity using the given authority. + * + * @param authority + * The authority of this provider. + * + * @return A {@link Uri} or null if this entity has not been stored yet. + */ + Uri uri(String authority); + + /** + * Returns the value identified by the given {@link FieldAdapter}. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the value to return. + * + * @return The value, maybe be null. + */ + T valueOf(FieldAdapter fieldAdapter); + + /** + * Returns the old value identified by the given {@link FieldAdapter}. This will be equal to the value returned by {@link #valueOf(FieldAdapter)} unless it + * has been overridden, in which case this returns the former value. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the value to return. + * + * @return The value, maybe be null. + */ + T oldValueOf(FieldAdapter fieldAdapter); + + /** + * Returns whether the given field has been overridden or not. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the field to check. + * + * @return true if the field has been overridden, false otherwise. + */ + boolean isUpdated(FieldAdapter fieldAdapter); + + /** + * Returns whether this adapter supports modifying values. + * + * @return true if the task values can be changed by this adapter, false otherwise. + */ + boolean isWriteable(); + + /** + * Returns whether any value has been modified. + * + * @return true if there are modified values, false otherwise. + */ + boolean hasUpdates(); + + /** + * Sets a value of the adapted entity. The value is identified by a {@link FieldAdapter}. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the value to set. + * @param value + * The new value. + */ + void set(FieldAdapter fieldAdapter, T value); + + /** + * Remove a value from the change set. In effect the respective field will keep it's old value. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the field to un-set. + */ + void unset(FieldAdapter fieldAdapter); + + /** + * Commit all changes to the database. + * + * @param db + * A writable database. + * + * @return The number of entries affected. This may be 0 if no fields have been changed. + */ + int commit(SQLiteDatabase db); + + /** + * Return the value of a temporary state field. The state of an entity is not committed to the database, it's only bound to the instances of this + * {@link EntityAdapter} and will be lost once it gets garbage collected. + * + * @param stateFieldAdater + * The {@link FieldAdapter} of a state field. + * + * @return The value of the state field. + */ + T getState(FieldAdapter stateFieldAdater); + + /** + * Set the value of a state field. This value is not stored in the database. Instead it only exists as long as this {@link EntityAdapter} exists. + * + * @param stateFieldAdater + * The {@link FieldAdapter} of the state field to set. + * @param value + * The new state value. + */ + void setState(FieldAdapter stateFieldAdater, T value); + + /*** + * Creates a {@link EntityAdapter} for a new entity initialized with the values of this entity (except for _ID). + * + * @return A new {@link EntityAdapter} having the same values. + */ + EntityAdapter duplicate(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java new file mode 100644 index 0000000..b2c9de3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java @@ -0,0 +1,109 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.provider.tasks.model.adapters.DateTimeFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.Collection; +import java.util.HashSet; + +import static java.util.Arrays.asList; + + +/** + * Adapter to read instance values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface InstanceAdapter extends EntityAdapter +{ + + Collection INSTANCE_COLUMN_NAMES = new HashSet<>(asList( + TaskContract.Instances.INSTANCE_START, + TaskContract.Instances.INSTANCE_START_SORTING, + TaskContract.Instances.INSTANCE_DUE, + TaskContract.Instances.INSTANCE_DUE_SORTING, + TaskContract.Instances.INSTANCE_DURATION, + TaskContract.Instances.INSTANCE_ORIGINAL_TIME, + TaskContract.Instances.TASK_ID, + TaskContract.Instances.DISTANCE_FROM_CURRENT, + "_id:1")); + + /** + * Adapter for the row id of a task instance. + */ + LongFieldAdapter _ID = new LongFieldAdapter(Instances._ID); + + /** + * Adapter for the due date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_DUE = new DateTimeFieldAdapter<>(Instances.INSTANCE_DUE, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the start date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_START = new DateTimeFieldAdapter<>(Instances.INSTANCE_START, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the start sorting of a task instance. + */ + LongFieldAdapter INSTANCE_START_SORTING = new LongFieldAdapter<>(Instances.INSTANCE_START_SORTING); + + /** + * Adapter for the due sorting of a task instance. + */ + LongFieldAdapter INSTANCE_DUE_SORTING = new LongFieldAdapter<>(Instances.INSTANCE_DUE_SORTING); + + /** + * Adapter for the original time of a task instance. + */ + DateTimeFieldAdapter INSTANCE_ORIGINAL_TIME = new DateTimeFieldAdapter<>(Instances.INSTANCE_ORIGINAL_TIME, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the distance of a task instance from the current instance. + */ + IntegerFieldAdapter DISTANCE_FROM_CURRENT = new IntegerFieldAdapter<>(Instances.DISTANCE_FROM_CURRENT); + + /** + * Adapter for the title of a task instance. + */ + StringFieldAdapter TITLE = new StringFieldAdapter<>(Tasks.TITLE); + + /** + * Adapter for the row id of the task. + */ + LongFieldAdapter TASK_ID = new LongFieldAdapter(Instances.TASK_ID); + + @Override + InstanceAdapter duplicate(); + + /** + * Returns a {@link TaskAdapter} for the task component of the instanced view. + * + * @return + */ + TaskAdapter taskAdapter(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java new file mode 100644 index 0000000..d72a673 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; +import org.dmfs.tasks.contract.TaskContract.TaskLists; + + +/** + * Adapter to read list values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface ListAdapter extends EntityAdapter +{ + /** + * Adapter for the row id of a task list. + */ + LongFieldAdapter _ID = new LongFieldAdapter(TaskLists._ID); + + /** + * Adapter for the _sync_id of a list. + */ + StringFieldAdapter SYNC_ID = new StringFieldAdapter(TaskLists._SYNC_ID); + + /** + * Adapter for the sync version of a list. + */ + StringFieldAdapter SYNC_VERSION = new StringFieldAdapter(TaskLists.SYNC_VERSION); + + /** + * Adapter for the account name of a list. + */ + StringFieldAdapter ACCOUNT_NAME = new StringFieldAdapter(TaskLists.ACCOUNT_NAME); + + /** + * Adapter for the account type of a list. + */ + StringFieldAdapter ACCOUNT_TYPE = new StringFieldAdapter(TaskLists.ACCOUNT_TYPE); + + /** + * Adapter for the owner of a list. + */ + StringFieldAdapter OWNER = new StringFieldAdapter(TaskLists.OWNER); + + /** + * Adapter for the name of a list. + */ + StringFieldAdapter LIST_NAME = new StringFieldAdapter(TaskLists.LIST_NAME); + + /** + * Adapter for the color of a list. + */ + IntegerFieldAdapter LIST_COLOR = new IntegerFieldAdapter(TaskLists.LIST_COLOR); + + /*** + * Creates a {@link ListAdapter} for a new task initialized with the values of this task (except for _ID). + * + * @return A new task having the same values. + */ + @Override + ListAdapter duplicate(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java new file mode 100644 index 0000000..4668cce --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java @@ -0,0 +1,362 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.provider.tasks.model.adapters.BinaryFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.BooleanFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.DateTimeFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.DateTimeIterableFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.DurationFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.RRuleFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.UrlFieldAdapter; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Tasks; + + +/** + * Adapter to read task values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface TaskAdapter extends EntityAdapter +{ + /** + * Adapter for the row id of a task. + */ + LongFieldAdapter _ID = new LongFieldAdapter(Tasks._ID); + + /** + * Adapter for the version of a task. + */ + LongFieldAdapter VERSION = new LongFieldAdapter<>(Tasks.VERSION); + + /** + * Adapter for the task row id of as instance. + */ + LongFieldAdapter INSTANCE_TASK_ID = new LongFieldAdapter(Instances.TASK_ID); + + /** + * Adapter for the row id of the list of a task. + */ + LongFieldAdapter LIST_ID = new LongFieldAdapter(Tasks.LIST_ID); + + /** + * Adapter for the owner of the list of a task. + */ + StringFieldAdapter LIST_OWNER = new StringFieldAdapter(Tasks.LIST_OWNER); + + /** + * Adapter for the row id of original instance of a task. + */ + LongFieldAdapter ORIGINAL_INSTANCE_ID = new LongFieldAdapter(Tasks.ORIGINAL_INSTANCE_ID); + + /** + * Adapter for the sync_id of original instance of a task. + */ + StringFieldAdapter ORIGINAL_INSTANCE_SYNC_ID = new StringFieldAdapter(Tasks.ORIGINAL_INSTANCE_SYNC_ID); + + /** + * Adapter for the original instance all day flag of a task. + */ + BooleanFieldAdapter ORIGINAL_INSTANCE_ALLDAY = new BooleanFieldAdapter(Tasks.ORIGINAL_INSTANCE_ALLDAY); + + /** + * Adapter for the parent_id of a task. + */ + LongFieldAdapter PARENT_ID = new LongFieldAdapter(Tasks.PARENT_ID); + + /** + * Adapter for the all day flag of a task. + */ + BooleanFieldAdapter IS_ALLDAY = new BooleanFieldAdapter(Tasks.IS_ALLDAY); + + /** + * Adapter for the percent complete value of a task. + */ + IntegerFieldAdapter PERCENT_COMPLETE = new IntegerFieldAdapter(Tasks.PERCENT_COMPLETE); + + /** + * Adapter for the status of a task. + */ + IntegerFieldAdapter STATUS = new IntegerFieldAdapter(Tasks.STATUS); + + /** + * Adapter for the priority value of a task. + */ + IntegerFieldAdapter PRIORITY = new IntegerFieldAdapter(Tasks.PRIORITY); + + /** + * Adapter for the classification value of a task. + */ + IntegerFieldAdapter CLASSIFICATION = new IntegerFieldAdapter(Tasks.CLASSIFICATION); + + /** + * Adapter for the list name of a task. + */ + StringFieldAdapter LIST_NAME = new StringFieldAdapter(Tasks.LIST_NAME); + + /** + * Adapter for the account name of a task. + */ + StringFieldAdapter ACCOUNT_NAME = new StringFieldAdapter(Tasks.ACCOUNT_NAME); + + /** + * Adapter for the account type of a task. + */ + StringFieldAdapter ACCOUNT_TYPE = new StringFieldAdapter(Tasks.ACCOUNT_TYPE); + + /** + * Adapter for the title of a task. + */ + StringFieldAdapter TITLE = new StringFieldAdapter(Tasks.TITLE); + + /** + * Adapter for the location of a task. + */ + StringFieldAdapter LOCATION = new StringFieldAdapter(Tasks.LOCATION); + + /** + * Adapter for the description of a task. + */ + StringFieldAdapter DESCRIPTION = new StringFieldAdapter(Tasks.DESCRIPTION); + + /** + * Adapter for the start date of a task. + */ + DateTimeFieldAdapter DTSTART = new DateTimeFieldAdapter(Tasks.DTSTART, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the original date of a task. + */ + DateTimeFieldAdapter ORIGINAL_INSTANCE_TIME = new DateTimeFieldAdapter(Tasks.ORIGINAL_INSTANCE_TIME, Tasks.TZ, + Tasks.ORIGINAL_INSTANCE_ALLDAY); + + /** + * Adapter for the raw start date timestamp of a task. + */ + LongFieldAdapter DTSTART_RAW = new LongFieldAdapter(Tasks.DTSTART); + + /** + * Adapter for the due date of a task. + */ + DateTimeFieldAdapter DUE = new DateTimeFieldAdapter(Tasks.DUE, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the raw due date timestamp of a task. + */ + LongFieldAdapter DUE_RAW = new LongFieldAdapter(Tasks.DUE); + + /** + * Adapter for the start date of a task. + */ + DurationFieldAdapter DURATION = new DurationFieldAdapter(Tasks.DURATION); + + /** + * Adapter for the dirty flag of a task. + */ + BooleanFieldAdapter _DIRTY = new BooleanFieldAdapter(Tasks._DIRTY); + + /** + * Adapter for the deleted flag of a task. + */ + BooleanFieldAdapter _DELETED = new BooleanFieldAdapter(Tasks._DELETED); + + /** + * Adapter for the completed date of a task. + */ + DateTimeFieldAdapter COMPLETED = new DateTimeFieldAdapter(Tasks.COMPLETED, null, null); + + /** + * Adapter for the created date of a task. + */ + DateTimeFieldAdapter CREATED = new DateTimeFieldAdapter(Tasks.CREATED, null, null); + + /** + * Adapter for the last modified date of a task. + */ + DateTimeFieldAdapter LAST_MODIFIED = new DateTimeFieldAdapter(Tasks.LAST_MODIFIED, null, null); + + /** + * Adapter for the URL of a task. + */ + UrlFieldAdapter URL = new UrlFieldAdapter(TaskContract.Tasks.URL); + + /** + * Adapter for the UID of a task. + */ + StringFieldAdapter _UID = new StringFieldAdapter(TaskContract.Tasks._UID); + + /** + * Adapter for the raw time zone of a task. + */ + StringFieldAdapter TIMEZONE_RAW = new StringFieldAdapter(TaskContract.Tasks.TZ); + + /** + * Adapter for the Color of the task. + */ + IntegerFieldAdapter LIST_COLOR = new IntegerFieldAdapter(TaskContract.Tasks.LIST_COLOR); + + /** + * Adapter for the access level of the task list. + */ + IntegerFieldAdapter LIST_ACCESS_LEVEL = new IntegerFieldAdapter(TaskContract.Tasks.LIST_ACCESS_LEVEL); + + /** + * Adapter for the visibility setting of the task list. + */ + BooleanFieldAdapter LIST_VISIBLE = new BooleanFieldAdapter(TaskContract.Tasks.VISIBLE); + + /** + * Adpater for the ID of the task. + */ + IntegerFieldAdapter TASK_ID = new IntegerFieldAdapter(TaskContract.Tasks._ID); + + /** + * Adapter for the IS_CLOSED flag of a task. + */ + BooleanFieldAdapter IS_CLOSED = new BooleanFieldAdapter(TaskContract.Tasks.IS_CLOSED); + + /** + * Adapter for the IS_NEW flag of a task. + */ + BooleanFieldAdapter IS_NEW = new BooleanFieldAdapter(TaskContract.Tasks.IS_NEW); + + /** + * Adapter for the PINNED flag of a task. + */ + BooleanFieldAdapter PINNED = new BooleanFieldAdapter(TaskContract.Tasks.PINNED); + + /** + * Adapter for the HAS_ALARMS flag of a task. + */ + BooleanFieldAdapter HAS_ALARMS = new BooleanFieldAdapter(TaskContract.Tasks.HAS_ALARMS); + + /** + * Adapter for the HAS_PROPERTIES flag of a task. + */ + BooleanFieldAdapter HAS_PROPERTIES = new BooleanFieldAdapter(TaskContract.Tasks.HAS_PROPERTIES); + + /** + * Adapter for the RRULE of a task. + */ + RRuleFieldAdapter RRULE = new RRuleFieldAdapter(TaskContract.Tasks.RRULE); + + /** + * Adapter for the RDATE of a task. + */ + DateTimeIterableFieldAdapter RDATE = new DateTimeIterableFieldAdapter(TaskContract.Tasks.RDATE, + TaskContract.Tasks.TZ); + + /** + * Adapter for the EXDATE of a task. + */ + DateTimeIterableFieldAdapter EXDATE = new DateTimeIterableFieldAdapter(TaskContract.Tasks.EXDATE, + TaskContract.Tasks.TZ); + + /** + * Adapter for the SYNC1 field of a task. + */ + BinaryFieldAdapter SYNC1 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC1); + + /** + * Adapter for the SYNC2 field of a task. + */ + BinaryFieldAdapter SYNC2 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC2); + + /** + * Adapter for the SYNC3 field of a task. + */ + BinaryFieldAdapter SYNC3 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC3); + + /** + * Adapter for the SYNC4 field of a task. + */ + BinaryFieldAdapter SYNC4 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC4); + + /** + * Adapter for the SYNC5 field of a task. + */ + BinaryFieldAdapter SYNC5 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC5); + + /** + * Adapter for the SYNC6 field of a task. + */ + BinaryFieldAdapter SYNC6 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC6); + + /** + * Adapter for the SYNC7 field of a task. + */ + BinaryFieldAdapter SYNC7 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC7); + + /** + * Adapter for the SYNC8 field of a task. + */ + BinaryFieldAdapter SYNC8 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC8); + + /** + * Adapter for the SYNC_VERSION field of a task. + */ + BinaryFieldAdapter SYNC_VERSION = new BinaryFieldAdapter(TaskContract.Tasks.SYNC_VERSION); + + /** + * Adapter for the SYNC_ID field of a task. + */ + StringFieldAdapter SYNC_ID = new StringFieldAdapter(TaskContract.Tasks._SYNC_ID); + + /** + * Adapter for the due date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_DUE = new DateTimeFieldAdapter(Instances.INSTANCE_DUE, Tasks.TZ, + Tasks.IS_ALLDAY); + + /** + * Adapter for the start date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_START = new DateTimeFieldAdapter(Instances.INSTANCE_START, Tasks.TZ, + Tasks.IS_ALLDAY); + + /** + * Returns whether the adapted task is recurring. + * + * @return true if the task is recurring, false otherwise. + */ + boolean isRecurring(); + + /** + * Returns whether any value that's relevant for recurrence has been modified thought this adapter. This returns true if any of + * {@link TaskContract.TaskColumns#DTSTART}, {@link TaskContract.TaskColumns#DUE},{@link TaskContract.TaskColumns#DURATION}, + * {@link TaskContract.TaskColumns#RRULE}, {@link TaskContract.TaskColumns#RDATE} or {@link TaskContract.TaskColumns#EXDATE} has been modified. + * + * @return true if the recurrence set has changed, false otherwise. + */ + boolean recurrenceUpdated(); + + /*** + * Creates a {@link TaskAdapter} for a new task initialized with the values of this task (except for _ID). + * + * @return A new task having the same values. + */ + @Override + TaskAdapter duplicate(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java new file mode 100644 index 0000000..0d6c24d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a binary value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class BinaryFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link BinaryFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public BinaryFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public byte[] getFrom(ContentValues values) + { + return values.getAsByteArray(mFieldName); + } + + + @Override + public byte[] getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getBlob(columnIdx); + } + + + @Override + public void setIn(ContentValues values, byte[] value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java new file mode 100644 index 0000000..ef7e948 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link Boolean} value from a {@link Cursor} or {@link ContentValues}. + *

+ * Implementation detail: + *

+ * The values are loaded and stored as 0 (for false) and 1 (for true). + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class BooleanFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link BooleanFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public BooleanFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Boolean getFrom(ContentValues values) + { + Integer value = values.getAsInteger(mFieldName); + + return value != null && value > 0; + } + + + @Override + public Boolean getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return !cursor.isNull(columnIdx) && cursor.getInt(columnIdx) > 0; + } + + + @Override + public void setIn(ContentValues values, Boolean value) + { + values.put(mFieldName, value ? 1 : 0); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java new file mode 100644 index 0000000..5c9159b --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java @@ -0,0 +1,243 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * Knows how to load and store {@link DateTime} values from a {@link Cursor} or {@link ContentValues}. + *

+ * {@link DateTime} values are stored as three separate values: + *

    + *
  • a timestamp in milliseconds since the epoch
  • + *
  • a time zone
  • + *
  • an allday flag
  • + *
+ *

+ * This adapter combines those three fields to a {@link DateTime} value. If the time zone field is null the time zone is always set to UTC. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class DateTimeFieldAdapter extends SimpleFieldAdapter +{ + private final String mTimestampField; + private final String mTzField; + private final String mAllDayField; + private final boolean mAllDayDefault; + + + /** + * Constructor for a new {@link DateTimeFieldAdapter}. + * + * @param timestampField + * The name of the field that holds the time stamp in milliseconds. + * @param tzField + * The name of the field that holds the time zone (as Olson ID). If the field name is null the time is always set to UTC. + * @param alldayField + * The name of the field that indicated that this time is a date not a date-time. If this fieldName is null all loaded values are + * non-allday. + */ + public DateTimeFieldAdapter(String timestampField, String tzField, String alldayField) + { + if (timestampField == null) + { + throw new IllegalArgumentException("timestampField must not be null"); + } + mTimestampField = timestampField; + mTzField = tzField; + mAllDayField = alldayField; + mAllDayDefault = false; + } + + + @Override + String fieldName() + { + return mTimestampField; + } + + + @Override + public DateTime getFrom(ContentValues values) + { + Long timestamp = values.getAsLong(mTimestampField); + if (timestamp == null) + { + // if the time stamp is null we return null + return null; + } + String timezone = mTzField == null ? null : values.getAsString(mTzField); + DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); + + // cache mAlldayField locally + String allDayField = mAllDayField; + + // set the allday flag appropriately + Integer allDayInt = allDayField == null ? null : values.getAsInteger(allDayField); + + if ((allDayInt != null && allDayInt != 0) || (allDayField == null && mAllDayDefault)) + { + value = value.toAllDay(); + } + + return value; + } + + + @Override + public DateTime getFrom(Cursor cursor) + { + int tsIdx = cursor.getColumnIndex(mTimestampField); + int tzIdx = mTzField == null ? -1 : cursor.getColumnIndex(mTzField); + int adIdx = mAllDayField == null ? -1 : cursor.getColumnIndex(mAllDayField); + + if (tsIdx < 0 || (mTzField != null && tzIdx < 0) || (mAllDayField != null && adIdx < 0)) + { + throw new IllegalArgumentException("At least one column is missing in cursor."); + } + + if (cursor.isNull(tsIdx)) + { + // if the time stamp is null we return null + return null; + } + + Long timestamp = cursor.getLong(tsIdx); + + String timezone = mTzField == null ? null : cursor.getString(tzIdx); + DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); + + // set the allday flag appropriately + Integer allDayInt = adIdx < 0 ? null : cursor.getInt(adIdx); + + if ((allDayInt != null && allDayInt != 0) || (mAllDayField == null && mAllDayDefault)) + { + value = value.toAllDay(); + } + return value; + } + + + @Override + public DateTime getFrom(Cursor cursor, ContentValues values) + { + int tsIdx; + int tzIdx; + int adIdx; + long timestamp; + String timeZoneId = null; + Integer allDay = 0; + + if (values != null && values.containsKey(mTimestampField)) + { + if (values.getAsLong(mTimestampField) == null) + { + // if the time stamp is null we return null + return null; + } + timestamp = values.getAsLong(mTimestampField); + } + else if (cursor != null && (tsIdx = cursor.getColumnIndex(mTimestampField)) >= 0) + { + if (cursor.isNull(tsIdx)) + { + // if the time stamp is null we return null + return null; + } + timestamp = cursor.getLong(tsIdx); + } + else + { + throw new IllegalArgumentException("Missing timestamp column."); + } + + if (mTzField != null) + { + if (values != null && values.containsKey(mTzField)) + { + timeZoneId = values.getAsString(mTzField); + } + else if (cursor != null && (tzIdx = cursor.getColumnIndex(mTzField)) >= 0) + { + timeZoneId = cursor.getString(tzIdx); + } + else + { + throw new IllegalArgumentException("Missing timezone column."); + } + } + + if (mAllDayField != null) + { + if (values != null && values.containsKey(mAllDayField)) + { + allDay = values.getAsInteger(mAllDayField); + } + else if (cursor != null && (adIdx = cursor.getColumnIndex(mAllDayField)) >= 0) + { + allDay = cursor.getInt(adIdx); + } + else + { + throw new IllegalArgumentException("Missing timezone column."); + } + } + + DateTime value = new DateTime(timeZoneId == null ? null : TimeZone.getTimeZone(timeZoneId), timestamp); + + if (allDay != 0) + { + value = value.toAllDay(); + } + return value; + } + + + @Override + public void setIn(ContentValues values, DateTime value) + { + if (value != null) + { + // just store all three parts separately + values.put(mTimestampField, value.getTimestamp()); + + if (mTzField != null) + { + TimeZone timezone = value.getTimeZone(); + values.put(mTzField, timezone == null ? null : timezone.getID()); + } + if (mAllDayField != null) + { + values.put(mAllDayField, value.isAllDay() ? 1 : 0); + } + } + else + { + // write timestamp only, other fields may still use allday and timezone + values.put(mTimestampField, (Long) null); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java new file mode 100644 index 0000000..3f1ebbc --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java @@ -0,0 +1,198 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; +import android.text.TextUtils; + +import org.dmfs.iterables.EmptyIterable; +import org.dmfs.iterables.Split; +import org.dmfs.iterables.decorators.DelegatingIterable; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * Knows how to load and store {@link Iterable}s of {@link DateTime} values from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class DateTimeIterableFieldAdapter extends SimpleFieldAdapter, EntityType> +{ + private final String mDateTimeListFieldName; + private final String mTimeZoneFieldName; + + + /** + * Constructor for a new {@link DateTimeIterableFieldAdapter}. + * + * @param datetimeListFieldName + * The name of the field that holds the {@link DateTime} list. + * @param timezoneFieldName + * The name of the field that holds the time zone name. + */ + public DateTimeIterableFieldAdapter(String datetimeListFieldName, String timezoneFieldName) + { + if (datetimeListFieldName == null) + { + throw new IllegalArgumentException("datetimeListFieldName must not be null"); + } + mDateTimeListFieldName = datetimeListFieldName; + mTimeZoneFieldName = timezoneFieldName; + } + + + @Override + String fieldName() + { + return mDateTimeListFieldName; + } + + + @Override + public Iterable getFrom(ContentValues values) + { + String datetimeList = values.getAsString(mDateTimeListFieldName); + if (datetimeList == null) + { + // no list, return an empty Iterable + return EmptyIterable.instance(); + } + + // create a new TimeZone for the given time zone string + String timezoneString = mTimeZoneFieldName == null ? null : values.getAsString(mTimeZoneFieldName); + TimeZone timeZone = timezoneString == null ? null : TimeZone.getTimeZone(timezoneString); + + return new DateTimeList(timeZone, datetimeList); + } + + + @Override + public Iterable getFrom(Cursor cursor) + { + int tdLIdx = cursor.getColumnIndex(mDateTimeListFieldName); + int tzIdx = mTimeZoneFieldName == null ? -1 : cursor.getColumnIndex(mTimeZoneFieldName); + + if (tdLIdx < 0 || (mTimeZoneFieldName != null && tzIdx < 0)) + { + throw new IllegalArgumentException("At least one column is missing in cursor."); + } + + if (cursor.isNull(tdLIdx)) + { + // if the time stamp list is null we return an empty Iterable + return EmptyIterable.instance(); + } + + String datetimeList = cursor.getString(tdLIdx); + + // create a new TimeZone for the given time zone string + String timezoneString = mTimeZoneFieldName == null ? null : cursor.getString(tzIdx); + TimeZone timeZone = timezoneString == null ? null : TimeZone.getTimeZone(timezoneString); + + return new DateTimeList(timeZone, datetimeList); + } + + + @Override + public Iterable getFrom(Cursor cursor, ContentValues values) + { + int tsIdx; + int tzIdx; + String datetimeList; + String timeZoneId = null; + + if (values != null && values.containsKey(mDateTimeListFieldName)) + { + if (values.getAsString(mDateTimeListFieldName) == null) + { + // the date times are null, so we return null + return EmptyIterable.instance(); + } + datetimeList = values.getAsString(mDateTimeListFieldName); + } + else if (cursor != null && (tsIdx = cursor.getColumnIndex(mDateTimeListFieldName)) >= 0) + { + if (cursor.isNull(tsIdx)) + { + // the date times are null, so we return an empty Iterable. + return EmptyIterable.instance(); + } + datetimeList = cursor.getString(tsIdx); + } + else + { + throw new IllegalArgumentException("Missing date time list column."); + } + + if (mTimeZoneFieldName != null) + { + if (values != null && values.containsKey(mTimeZoneFieldName)) + { + timeZoneId = values.getAsString(mTimeZoneFieldName); + } + else if (cursor != null && (tzIdx = cursor.getColumnIndex(mTimeZoneFieldName)) >= 0) + { + timeZoneId = cursor.getString(tzIdx); + } + else + { + throw new IllegalArgumentException("Missing timezone column."); + } + } + + // create a new TimeZone for the given time zone string + TimeZone timeZone = timeZoneId == null ? null : TimeZone.getTimeZone(timeZoneId); + + return new DateTimeList(timeZone, datetimeList); + } + + + @Override + public void setIn(ContentValues values, Iterable value) + { + if (value != null) + { + String stringValue = TextUtils.join(",", new Mapped<>(dt -> dt.isFloating() ? dt : dt.shiftTimeZone(DateTime.UTC), value)); + values.put(mDateTimeListFieldName, stringValue.isEmpty() ? null : stringValue); + } + else + { + values.put(mDateTimeListFieldName, (String) null); + } + } + + + private final class DateTimeList extends DelegatingIterable + { + + public DateTimeList(TimeZone timeZone, String dateTimeList) + { + super(new Mapped<>( + datetime -> !datetime.isFloating() && timeZone != null ? datetime.shiftTimeZone(timeZone) : datetime, + new Mapped( + charSequence -> DateTime.parse(timeZone, charSequence.toString()), + new Split(dateTimeList, ',')))); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java new file mode 100644 index 0000000..5a5f8eb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java @@ -0,0 +1,106 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.rfc5545.Duration; + + +/** + * Knows how to load and store {@link Duration} values from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class DurationFieldAdapter extends SimpleFieldAdapter +{ + + private final String mFieldName; + + + /** + * Constructor for a new {@link DurationFieldAdapter}. + * + * @param urlField + * The field name that holds the {@link Duration}. + */ + public DurationFieldAdapter(String urlField) + { + if (urlField == null) + { + throw new IllegalArgumentException("urlField must not be null"); + } + mFieldName = urlField; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Duration getFrom(ContentValues values) + { + String rawValue = values.getAsString(mFieldName); + if (rawValue == null) + { + return null; + } + + return Duration.parse(rawValue); + } + + + @Override + public Duration getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + + if (cursor.isNull(columnIdx)) + { + return null; + } + + return Duration.parse(cursor.getString(columnIdx)); + } + + + @Override + public void setIn(ContentValues values, Duration value) + { + if (value != null) + { + values.put(mFieldName, value.toString()); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java new file mode 100644 index 0000000..e9fe287 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java @@ -0,0 +1,148 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a specific field from or to {@link ContentValues} or from {@link Cursor}s. + * + * @param + * The type of the value this adapter stores. + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public interface FieldAdapter +{ + + /** + * Check if a value is present and non-null in the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} to check. + * + * @return + */ + boolean existsIn(ContentValues values); + + /** + * Check if a value is present (may be null) in the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} to check. + * + * @return + */ + boolean isSetIn(ContentValues values); + + /** + * Get the value from the given {@link ContentValues} + * + * @param values + * The {@link ContentValues} that contain the value to return. + * + * @return The value. + */ + FieldType getFrom(ContentValues values); + + /** + * Check if a value is present and non-null in the given {@link Cursor}. + * + * @param cursor + * The {@link Cursor} that contains the value to check. + * + * @return + */ + boolean existsIn(Cursor cursor); + + /** + * Get the value from the given {@link Cursor} + * + * @param cursor + * The {@link Cursor} that contain the value to return. + * + * @return The value. + */ + FieldType getFrom(Cursor cursor); + + /** + * Check if a value is present and non-null in the given {@link Cursor} or {@link ContentValues}. + * + * @param cursor + * The {@link Cursor} that contains the value to check. + * @param values + * The {@link ContentValues} that contains the value to check. + * + * @return + */ + boolean existsIn(Cursor cursor, ContentValues values); + + /** + * Get the value from the given {@link Cursor} or {@link ContentValues}, with the {@link ContentValues} taking precedence over the cursor values. + * + * @param cursor + * The {@link Cursor} that contains the value to return. + * @param values + * The {@link ContentValues} that contains the value to return. + * + * @return The value. + */ + FieldType getFrom(Cursor cursor, ContentValues values); + + /** + * Set a value in the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} to store the new value in. + * @param value + * The new value to store. + */ + void setIn(ContentValues values, FieldType value); + + /** + * Remove a value from the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} from which to remove the value. + */ + void removeFrom(ContentValues values); + + /** + * Copy the value from a {@link Cursor} to the given {@link ContentValues}. + * + * @param source + * The {@link Cursor} that contains the value to copy. + * @param dest + * The {@link ContentValues} to receive the value. + */ + void copyValue(Cursor source, ContentValues dest); + + /** + * Copy the value from {@link ContentValues} to another {@link ContentValues} object. + * + * @param source + * The {@link ContentValues} that contains the value to copy. + * @param dest + * The {@link ContentValues} to receive the value. + */ + void copyValue(ContentValues source, ContentValues dest); + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java new file mode 100644 index 0000000..28b8a01 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link Float} value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class FloatFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link FloatFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public FloatFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Float getFrom(ContentValues values) + { + return values.getAsFloat(mFieldName); + } + + + @Override + public Float getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getFloat(columnIdx); + } + + + @Override + public void setIn(ContentValues values, Float value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java new file mode 100644 index 0000000..933c5e8 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store an {@link Integer} from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class IntegerFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link IntegerFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public IntegerFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Integer getFrom(ContentValues values) + { + // return the value as Integer + return values.getAsInteger(mFieldName); + } + + + @Override + public Integer getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getInt(columnIdx); + } + + + @Override + public void setIn(ContentValues values, Integer value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java new file mode 100644 index 0000000..517ca23 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link Long} value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class LongFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link LongFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public LongFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Long getFrom(ContentValues values) + { + return values.getAsLong(mFieldName); + } + + + @Override + public Long getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getLong(columnIdx); + } + + + @Override + public void setIn(ContentValues values, Long value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java new file mode 100644 index 0000000..201b075 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java @@ -0,0 +1,122 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; +import org.dmfs.rfc5545.recur.RecurrenceRule; + + +/** + * Knows how to load and store a {@link RecurrenceRule} from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class RRuleFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link RRuleFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public RRuleFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public RecurrenceRule getFrom(ContentValues values) + { + String rrule = values.getAsString(mFieldName); + if (rrule == null) + { + return null; + } + try + { + return new RecurrenceRule(rrule); + } + catch (InvalidRecurrenceRuleException e) + { + throw new IllegalArgumentException("can not parse RRULE '" + rrule + "'", e); + } + } + + + @Override + public RecurrenceRule getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + if (cursor.isNull(columnIdx)) + { + return null; + } + + try + { + return new RecurrenceRule(cursor.getString(columnIdx)); + } + catch (InvalidRecurrenceRuleException e) + { + throw new IllegalArgumentException("can not parse RRULE '" + cursor.getString(columnIdx) + "'", e); + } + } + + + @Override + public void setIn(ContentValues values, RecurrenceRule value) + { + if (value != null) + { + values.put(mFieldName, value.toString()); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java new file mode 100644 index 0000000..2752ba9 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java @@ -0,0 +1,100 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * An abstract {@link FieldAdapter} that implements a couple of methods as used by most simple FieldAdapters. + * + * @param + * The Type of the field this adapter handles. + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public abstract class SimpleFieldAdapter implements FieldAdapter +{ + + /** + * Returns the sole field name of this adapter. + * + * @return + */ + abstract String fieldName(); + + + @Override + public boolean existsIn(ContentValues values) + { + return values.get(fieldName()) != null; + } + + + @Override + public boolean isSetIn(ContentValues values) + { + return values.containsKey(fieldName()); + } + + + @Override + public boolean existsIn(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(fieldName()); + return columnIdx >= 0 && !cursor.isNull(columnIdx); + } + + + @Override + public FieldType getFrom(Cursor cursor, ContentValues values) + { + return values.containsKey(fieldName()) ? getFrom(values) : getFrom(cursor); + } + + + @Override + public boolean existsIn(Cursor cursor, ContentValues values) + { + return existsIn(values) || existsIn(cursor); + } + + + @Override + public void removeFrom(ContentValues values) + { + values.remove(fieldName()); + } + + + @Override + public void copyValue(Cursor cursor, ContentValues values) + { + setIn(values, getFrom(cursor)); + } + + + @Override + public void copyValue(ContentValues oldValues, ContentValues newValues) + { + setIn(newValues, getFrom(oldValues)); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java new file mode 100644 index 0000000..4c5311a --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link String} value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class StringFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link StringFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public StringFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public String getFrom(ContentValues values) + { + // return the value as String + return values.getAsString(mFieldName); + } + + + @Override + public String getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.getString(columnIdx); + } + + + @Override + public void setIn(ContentValues values, String value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java new file mode 100644 index 0000000..53496b8 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import java.net.URI; +import java.net.URL; + + +/** + * Knows how to load and store {@link URL} values from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class UrlFieldAdapter extends SimpleFieldAdapter +{ + + private final String mFieldName; + + + /** + * Constructor for a new {@link UrlFieldAdapter}. + * + * @param urlField + * The field name that holds the URL. + */ + public UrlFieldAdapter(String urlField) + { + if (urlField == null) + { + throw new IllegalArgumentException("urlField must not be null"); + } + mFieldName = urlField; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public URI getFrom(ContentValues values) + { + return values.get(mFieldName) == null ? null : URI.create(values.getAsString(mFieldName)); + } + + + @Override + public URI getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + + return cursor.isNull(columnIdx) ? null : URI.create(cursor.getString(columnIdx)); + } + + + @Override + public void setIn(ContentValues values, URI value) + { + if (value != null) + { + values.put(mFieldName, value.toASCIIString()); + } + else + { + values.putNull(mFieldName); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java new file mode 100644 index 0000000..8ae6323 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.model.EntityAdapter; + + +/** + * @author Marten Gajda + */ +public interface EntityProcessor> +{ + T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); + + T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); + + void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java new file mode 100644 index 0000000..87f7379 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors; + +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import org.dmfs.provider.tasks.model.EntityAdapter; + + +/** + * @author Marten Gajda + */ +public final class Logging> implements EntityProcessor +{ + public static final String TAG = "Logging EntityProcessor"; + private final EntityProcessor mDelegate; + + + public Logging(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + Log.d(TAG, "before insert"); + T result = mDelegate.insert(db, entityAdapter, isSyncAdapter); + Log.d(TAG, "after insert on " + entityAdapter.id()); + return result; + } + + + @Override + public T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + Log.d(TAG, "before update of " + entityAdapter.id()); + T result = mDelegate.update(db, entityAdapter, isSyncAdapter); + Log.d(TAG, "after update of " + entityAdapter.id()); + return result; + } + + + @Override + public void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + Log.d(TAG, "before delete of " + entityAdapter.id()); + mDelegate.delete(db, entityAdapter, isSyncAdapter); + Log.d(TAG, "after delete of " + entityAdapter.id()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java new file mode 100644 index 0000000..d86f026 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java @@ -0,0 +1,50 @@ +/* + * Copyright 2018 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.model.EntityAdapter; + + +/** + * A simple No-Op {@link EntityProcessor}. + * + * @author Marten Gajda + */ +public final class NoOpProcessor> implements EntityProcessor +{ + @Override + public T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + return entityAdapter; + } + + + @Override + public T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + return entityAdapter; + } + + + @Override + public void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + // do nothing + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java new file mode 100644 index 0000000..8c98fd0 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java @@ -0,0 +1,337 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.predicate.composite.AnyOf; +import org.dmfs.jems.predicate.composite.Not; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.Timestamps; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.Duration; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceList; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.HashSet; +import java.util.TimeZone; + +import static java.util.Arrays.asList; + + +/** + * An instance {@link EntityProcessor} detaches completed instances at the start of a recurring task. + * + * @author Marten Gajda + */ +public final class Detaching implements EntityProcessor +{ + + private final EntityProcessor mDelegate; + private final EntityProcessor mTaskDelegate; + + + public Detaching(EntityProcessor delegate, EntityProcessor taskDelegate) + { + mDelegate = delegate; + mTaskDelegate = taskDelegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // just delegate for now + // if we ever support inserting instances, we'll have to make sure that inserting a completed instance results in a detached task + return mDelegate.insert(db, entityAdapter, isSyncAdapter); + } + + + /** + * Detach the given instance if all of the following conditions are met + *

+ * - The instance is a recurrence instance (INSTANCE_ORIGINAL_TIME != null) + * - and the task has been closed (IS_CLOSED != 0) + * - and the instance is the first non-closed instance (DISTANCE_FROM_CURRENT==0). + *

+ */ + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + if (entityAdapter.valueOf(InstanceAdapter.DISTANCE_FROM_CURRENT) != 0 // not the first open task + + // not closed, note we can't use IS_CLOSED at this point because its not updated yet + || (!new HashSet<>(asList(TaskContract.Tasks.STATUS_COMPLETED, TaskContract.Tasks.STATUS_CANCELLED)).contains( + entityAdapter.valueOf(new IntegerFieldAdapter<>(TaskContract.Tasks.STATUS)))) + + // not recurring + || entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME) == null) + { + // not a detachable instance + return mDelegate.update(db, entityAdapter, isSyncAdapter); + } + // update instance accordingly and detach it + return detachAll(db, mDelegate.update(db, entityAdapter, isSyncAdapter)); + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // just delegate + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Detach all closed instances preceding the given one. + *

+ * TODO: this method needs some refactoring + */ + private InstanceAdapter detachAll(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + // keep some values for later + long masterId = new FirstPresent<>( + new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.ORIGINAL_INSTANCE_ID))), + new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.TASK_ID)))).value(); + DateTime instanceOriginalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); + + // detach instances which are completed + try (Cursor instances = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, + null, + String.format("%s < 0 and %s == ?", TaskContract.Instances.DISTANCE_FROM_CURRENT, TaskContract.Instances.ORIGINAL_INSTANCE_ID), + new String[] { String.valueOf(masterId) }, + null, + null, + null)) + { + while (instances.moveToNext()) + { + detachSingle(db, new CursorContentValuesInstanceAdapter(instances, new ContentValues())); + } + } + + // move the master to the first incomplete task + try (Cursor task = db.query(TaskDatabaseHelper.Tables.TASKS_VIEW, + null, + String.format("%s == ?", TaskContract.Tasks._ID), + new String[] { String.valueOf(masterId) }, + null, + null, + null)) + { + if (task.moveToFirst()) + { + TaskAdapter masterTask = new CursorContentValuesTaskAdapter(task, new ContentValues()); + DateTime oldStart = new FirstPresent<>( + new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); + + // assume we have no instances left + boolean noInstances = true; + + // update RRULE, if existent + RecurrenceRule rule = masterTask.valueOf(TaskAdapter.RRULE); + int count = 0; + if (rule != null) + { + RecurrenceSet ruleSet = new RecurrenceSet(); + ruleSet.addInstances(new RecurrenceRuleAdapter(rule)); + if (rule.getCount() == null) + { + // rule has no count limit, allowing us to exclude exdates + ruleSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); + } + RecurrenceSetIterator ruleIterator = ruleSet.iterator( + oldStart.getTimeZone(), + oldStart.getTimestamp()); + + // move DTSTART to next RRULE instance which is > instanceOriginalTime + // reduce COUNT by the number of skipped instances, if present + while (count < 1000 && ruleIterator.hasNext()) + { + DateTime inst = new DateTime(oldStart.getTimeZone(), ruleIterator.next()); + if (instanceOriginalTime.before(inst)) + { + updateStart(masterTask, inst); + noInstances = false; // just found another instance + break; + } + count += 1; + } + + if (noInstances) + { + // remove the RRULE but keep a mask for the old start + masterTask.set(TaskAdapter.EXDATE, + new Joined<>(new SingletonIterable<>(oldStart), new Sieved<>(new Not<>(oldStart::equals), masterTask.valueOf(TaskAdapter.EXDATE)))); + masterTask.set(TaskAdapter.RRULE, null); + } + else + { + // adjust COUNT if present + if (rule.getCount() != null) + { + rule.setCount(rule.getCount() - count); + masterTask.set(TaskAdapter.RRULE, rule); + } + } + } + + DateTime newStart = new FirstPresent<>( + new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); + + // update RDATE and EXDATE + masterTask.set(TaskAdapter.RDATE, new Sieved<>(instanceOriginalTime::before, masterTask.valueOf(TaskAdapter.RDATE))); + masterTask.set(TaskAdapter.EXDATE, + new Sieved<>(new AnyOf<>(instanceOriginalTime::before, newStart::equals), masterTask.valueOf(TaskAdapter.EXDATE))); + + // First check if we still have any RDATE instances left + // TODO: 6 lines for something we should be able to express in one simple expression, we need to straighten lib-recur!! + RecurrenceSet rdateSet = new RecurrenceSet(); + rdateSet.addInstances(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.RDATE)).value())); + rdateSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); + RecurrenceSetIterator iterator = rdateSet.iterator(DateTime.UTC, Long.MIN_VALUE); + iterator.fastForward(Long.MIN_VALUE + 1); // skip bogus start + noInstances &= !iterator.hasNext(); + + if (noInstances) + { + // no more instances left, remove the master + mTaskDelegate.delete(db, masterTask, false); + } + else + { + if (masterTask.valueOf(TaskAdapter.RRULE) == null) + { + // we don't have any RRULE, allowing us to adjust DTSTART/DUE to the first RDATE + DateTime start = new DateTime(iterator.next()); + if (masterTask.valueOf(TaskAdapter.IS_ALLDAY)) + { + start = start.toAllDay(); + } + else if (masterTask.valueOf(TaskAdapter.TIMEZONE_RAW) != null) + { + start = start.shiftTimeZone(TimeZone.getTimeZone(masterTask.valueOf(TaskAdapter.TIMEZONE_RAW))); + } + updateStart(masterTask, start); + } + + // we still have instances, update the database + mTaskDelegate.update(db, masterTask, false); + } + } + } + + return entityAdapter; + } + + + private void updateStart(TaskAdapter task, DateTime newStart) + { + // this new instance becomes the new start (or due if we don't have a start) + if (task.valueOf(TaskAdapter.DTSTART) != null) + { + DateTime oldStart = task.valueOf(TaskAdapter.DTSTART); + task.set(TaskAdapter.DTSTART, newStart); + if (task.valueOf(TaskAdapter.DUE) != null) + { + long duration = task.valueOf(TaskAdapter.DUE).getTimestamp() - oldStart.getTimestamp(); + task.set(TaskAdapter.DUE, + newStart.addDuration( + new Duration(1, (int) (duration / (3600 * 24 * 1000)), (int) (duration % (3600 * 24 * 1000)) / 1000))); + } + } + else + { + task.set(TaskAdapter.DUE, newStart); + } + + } + + + /** + * Detach the given instance. + *

+ * - clone the override into a new deleted task (set _DELETED == 1) + * - detach the original override by removing the ORIGINAL_INSTANCE_ID, ORIGINAL_INSTANCE_SYNC_ID, ORIGINAL_INSTANCE_START and ORIGINAL_INSTANCE_ALLDAY + * (i.e. all columns which relate this to the original) + * - wipe _SYNC_ID, _UID and all sync columns (make this an unsynced task) + */ + private void detachSingle(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + TaskAdapter original = entityAdapter.taskAdapter(); + TaskAdapter cloneAdapter = original.duplicate(); + + // first prepare the original to resemble the same instance but as a new, detached task + original.set(TaskAdapter.SYNC_ID, null); + original.set(TaskAdapter.SYNC_VERSION, null); + original.set(TaskAdapter.SYNC1, null); + original.set(TaskAdapter.SYNC2, null); + original.set(TaskAdapter.SYNC3, null); + original.set(TaskAdapter.SYNC4, null); + original.set(TaskAdapter.SYNC5, null); + original.set(TaskAdapter.SYNC6, null); + original.set(TaskAdapter.SYNC7, null); + original.set(TaskAdapter.SYNC8, null); + original.set(TaskAdapter._UID, null); + original.set(TaskAdapter._DIRTY, true); + original.set(TaskAdapter.ORIGINAL_INSTANCE_ID, null); + original.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); + original.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, null); + original.unset(TaskAdapter.COMPLETED); + original.commit(db); + + // wipe INSTANCE_ORIGINAL_TIME from instances entry + ContentValues noOriginalTime = new ContentValues(); + noOriginalTime.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + db.update(TaskDatabaseHelper.Tables.INSTANCES, noOriginalTime, "_ID = ?", new String[] { String.valueOf(entityAdapter.id()) }); + + // reset the clone to be a deleted instance + cloneAdapter.set(TaskAdapter._DELETED, true); + // remove joined field values + cloneAdapter.unset(TaskAdapter.LIST_ACCESS_LEVEL); + cloneAdapter.unset(TaskAdapter.LIST_COLOR); + cloneAdapter.unset(TaskAdapter.LIST_NAME); + cloneAdapter.unset(TaskAdapter.LIST_OWNER); + cloneAdapter.unset(TaskAdapter.LIST_VISIBLE); + cloneAdapter.unset(TaskAdapter.ACCOUNT_NAME); + cloneAdapter.unset(TaskAdapter.ACCOUNT_TYPE); + cloneAdapter.commit(db); + + // note, we don't have to create an instance for the clone because it's deleted + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java new file mode 100644 index 0000000..f02ed1d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java @@ -0,0 +1,284 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.decorators.Filtered; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.iterators.filters.NoneOf; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.handler.PropertyHandler; +import org.dmfs.provider.tasks.handler.PropertyHandlerFactory; +import org.dmfs.provider.tasks.model.ContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + + +/** + * An instance {@link EntityProcessor} which delegates to the appropriate task {@link EntityProcessor}. + * + * @author Marten Gajda + */ +public final class TaskValueDelegate implements EntityProcessor +{ + private final static Iterable> SPECIAL_FIELD_ADAPTERS = new Seq<>( + TaskAdapter.SYNC1, + TaskAdapter.SYNC2, + TaskAdapter.SYNC3, + TaskAdapter.SYNC4, + TaskAdapter.SYNC5, + TaskAdapter.SYNC6, + TaskAdapter.SYNC7, + TaskAdapter.SYNC8, + TaskAdapter.SYNC_ID, + TaskAdapter.SYNC_VERSION, + // unset any list and read-only fields + TaskAdapter.VERSION, + TaskAdapter.ACCOUNT_NAME, + TaskAdapter.ACCOUNT_TYPE, + TaskAdapter.LIST_VISIBLE, + TaskAdapter.LIST_COLOR, + TaskAdapter.LIST_NAME, + TaskAdapter.LIST_ACCESS_LEVEL, + TaskAdapter.LIST_OWNER, + TaskAdapter._DELETED, + TaskAdapter._DIRTY, + TaskAdapter.IS_NEW, + TaskAdapter.IS_CLOSED, + TaskAdapter.HAS_PROPERTIES, + TaskAdapter.HAS_ALARMS, + TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, /* this will be resolved automatically */ + // also unset any recurrence fields + TaskAdapter.RRULE, + TaskAdapter.RDATE, + TaskAdapter.EXDATE, + TaskAdapter.CREATED, + TaskAdapter.LAST_MODIFIED + ); + + private final EntityProcessor mDelegate; + + + public TaskValueDelegate(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + TaskAdapter taskAdapter = entityAdapter.taskAdapter(); + Long masterTaskId = null; + if (taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + // this is going to be an override to an existing task - make sure we add an RDATE first + masterTaskId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + DateTime originalTime = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME); + // get the master and add an rdate + try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null /* all */, TaskContract.Tasks._ID + "=" + masterTaskId, null, null, null, null)) + { + if (c.moveToFirst()) + { + TaskAdapter masterTaskAdapter = new CursorContentValuesTaskAdapter(masterTaskId, c, new ContentValues()); + if (masterTaskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + throw new IllegalArgumentException("Can't add an instance to an override instance"); + } + DateTime masterDate = new Backed(new FirstPresent<>(new Seq<>( + new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DUE)))), () -> null).value(); + if (!masterTaskAdapter.isRecurring() && masterDate != null) + { + // master is not recurring yet, also add its start as an RDATE + appendDate(masterTaskAdapter, TaskAdapter.RDATE, TaskAdapter.EXDATE, masterDate); + } + // TODO: should we throw if the new master has no DTSTART? + appendDate(masterTaskAdapter, TaskAdapter.RDATE, TaskAdapter.EXDATE, originalTime); + mDelegate.update(db, masterTaskAdapter, false); + + } + else + { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, "No task with _ID %d found", masterTaskId)); + } + } + } + + // move on with inserting the instance + TaskAdapter taskResult = mDelegate.insert(db, entityAdapter.taskAdapter(), false); + + if (masterTaskId != null) + { + // we just cloned the master task into a new instance, we need to copy the properties as well + copyProperties(db, masterTaskId, taskResult.id()); + } + + try (Cursor c = db.query(TaskDatabaseHelper.Tables.INSTANCES, new String[] { TaskContract.Instances._ID }, + TaskContract.Instances.TASK_ID + "=" + taskResult.id(), null, null, null, null)) + { + // the cursor should contain exactly one row after this operation + c.moveToFirst(); + return new ContentValuesInstanceAdapter(c.getLong(0), new ContentValues()); + } + } + + + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // if this is the master of a recurring task, we create a new instance or update an existing one for this override, otherwise we just delegate + TaskAdapter taskAdapter = entityAdapter.taskAdapter(); + if (taskAdapter.isRecurring()) + { + // clone the task to create an unsynced override + InstanceAdapter newInstanceAdapter = entityAdapter.duplicate(); + TaskAdapter override = newInstanceAdapter.taskAdapter(); + override.set(TaskAdapter.ORIGINAL_INSTANCE_ID, entityAdapter.valueOf(InstanceAdapter.TASK_ID)); + override.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME)); + // unset all fields which have special meaning + for (FieldAdapter specialFieldAdapter : SPECIAL_FIELD_ADAPTERS) + { + override.unset(specialFieldAdapter); + } + + // make sure we update DTSTART and DUE to match the instance values (unless they are set explicitly) + if (!taskAdapter.isUpdated(TaskAdapter.DTSTART)) + { + // set DTSTART to the instance start + override.set(TaskAdapter.DTSTART, newInstanceAdapter.valueOf(InstanceAdapter.INSTANCE_START)); + } + if (!taskAdapter.isUpdated(TaskAdapter.DUE) && !taskAdapter.isUpdated(TaskAdapter.DURATION)) + { + // set DUE to the effective instance DUE and wipe any duration + override.set(TaskAdapter.DUE, newInstanceAdapter.valueOf(InstanceAdapter.INSTANCE_DUE)); + override.set(TaskAdapter.DURATION, null); + } + // copy original instance allday flag + override.set(TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, taskAdapter.valueOf(TaskAdapter.IS_ALLDAY)); + + TaskAdapter newTask = mDelegate.insert(db, override, false); + + copyProperties(db, taskAdapter.id(), newTask.id()); + } + else + { + // this is a non-recurring task or it's already an override, just delegate the update + mDelegate.update(db, taskAdapter, false); + } + return entityAdapter; + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // deleted instances are converted to deleted tasks (for non-recurring tasks) or exdates (for recurring tasks). + TaskAdapter taskAdapter = entityAdapter.taskAdapter(); + + if (taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + /* this is an override - we have to: + * - mark it deleted + * - add an exclusion to the master task + * + * TODO: if this instance was added by an RDATE, just remove the RDATE + * TODO: if this is the first instance, consider moving the recurrence start instead of adding an exdate + * TODO: if this is the last instance of a finite task, consider just setting a new recurrence end + */ + long masterTaskId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + DateTime originalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); + + // delete the override + mDelegate.delete(db, taskAdapter, false); + + // get the master and add an exdate + try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null /* all */, TaskContract.Tasks._ID + "=" + masterTaskId, null, null, null, null)) + { + if (c.moveToFirst()) + { + TaskAdapter masterTaskAdapter = new CursorContentValuesTaskAdapter(masterTaskId, c, new ContentValues()); + appendDate(masterTaskAdapter, TaskAdapter.EXDATE, TaskAdapter.RDATE, originalTime); + mDelegate.update(db, masterTaskAdapter, false); + } + } + } + else if (taskAdapter.isRecurring()) + { + // TODO: if this is the first instance, consider moving the recurrence start instead of adding an exdate + // TODO: if this is the last instance of a finite task, consider just setting a new recurrence end + appendDate(taskAdapter, TaskAdapter.EXDATE, TaskAdapter.RDATE, entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME)); + mDelegate.update(db, taskAdapter, false); + } + else + { + // task is non-recurring, delete it as a non-sync-adapter (effectively setting the _deleted flag) + mDelegate.delete(db, taskAdapter, false); + } + } + + + private void appendDate(TaskAdapter taskAdapter, FieldAdapter, TaskAdapter> addfieldAdapter, FieldAdapter, TaskAdapter> removefieldAdapter, DateTime dateTime) + { + taskAdapter.set(addfieldAdapter, new Joined<>(new Filtered<>(taskAdapter.valueOf(addfieldAdapter), new NoneOf<>(dateTime)), new Seq<>(dateTime))); + taskAdapter.set(removefieldAdapter, new Filtered<>(taskAdapter.valueOf(removefieldAdapter), new NoneOf<>(dateTime))); + } + + + /** + * Copy the properties from the give original task to the new task. + * + * @param db + * The {@link SQLiteDatabase} + * @param originalId + * The ID of the task of which to copy the properties + * @param newId + * The ID of the task to copy the properties to. + */ + private void copyProperties(SQLiteDatabase db, long originalId, long newId) + { + // for each property of the original task + try (Cursor c = db.query(TaskDatabaseHelper.Tables.PROPERTIES, null /* all */, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Properties.TASK_ID, originalId), null, null, null, null)) + { + // load the property and insert it for the new task + ContentValues values = new ContentValues(c.getColumnCount()); + while (c.moveToNext()) + { + values.clear(); + DatabaseUtils.cursorRowToContentValues(c, values); + PropertyHandler ph = PropertyHandlerFactory.get(values.getAsString(TaskContract.Properties.MIMETYPE)); + ph.insert(db, newId, ph.cloneForNewTask(newId, values), false); + } + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java new file mode 100644 index 0000000..238e650 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java @@ -0,0 +1,186 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.First; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + + +/** + * An {@link EntityProcessor} which validates the instance data. + * + * @author Marten Gajda + */ +public final class Validating implements EntityProcessor +{ + private final static Iterable> INSTANCE_FIELD_ADAPTERS = new Seq<>( + InstanceAdapter._ID, + InstanceAdapter.INSTANCE_START, + InstanceAdapter.INSTANCE_START_SORTING, + InstanceAdapter.INSTANCE_DUE, + InstanceAdapter.INSTANCE_DUE_SORTING, + InstanceAdapter.INSTANCE_ORIGINAL_TIME, + InstanceAdapter.DISTANCE_FROM_CURRENT, + InstanceAdapter.TASK_ID); + + private final static Iterable> RECURRENCE_FIELD_ADAPTERS = new Seq<>( + TaskAdapter.RRULE, + TaskAdapter.RDATE, + TaskAdapter.EXDATE); + + private static final Iterable> ORIGINAL_INSTANCE_FIELD_ADAPTERS = new Seq<>( + TaskAdapter.ORIGINAL_INSTANCE_ID, + TaskAdapter.ORIGINAL_INSTANCE_TIME, + TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, + TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID); + + private final EntityProcessor mDelegate; + + + public Validating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + validateIsSyncAdapter(isSyncAdapter); + validateValues(entityAdapter); + validateInstanceIsNew(db, entityAdapter); + + return mDelegate.insert(db, entityAdapter, false); + } + + + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + validateIsSyncAdapter(isSyncAdapter); + validateValues(entityAdapter); + validateOriginalInstanceValues(entityAdapter); + return mDelegate.update(db, entityAdapter, false); + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + validateIsSyncAdapter(isSyncAdapter); + mDelegate.delete(db, entityAdapter, false); + } + + + private void validateIsSyncAdapter(boolean isSyncAdapter) + { + if (isSyncAdapter) + { + throw new UnsupportedOperationException("Sync adapters are not expected to write to the instances table."); + } + } + + + private void validateInstanceIsNew(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + Optional instanceId = new NullSafe<>(entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)); + Optional instanceTime = new NullSafe<>(entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)); + + // check if ORIGINAL_INSTANCE_ID and ORIGINAL_INSTANCE_TIME are both present/absent at the same time + if (instanceId.isPresent() != instanceTime.isPresent()) + { + throw new IllegalArgumentException(String.format("%s and %s must either be both absent or both present", + TaskContract.Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Tasks.ORIGINAL_INSTANCE_TIME)); + } + + if (instanceId.isPresent()) + { + String timeStampString = Long.toString(instanceTime.value().getTimestamp()); + // Make sure there is no instance at the given time already + try (Cursor c = db.query( + TaskDatabaseHelper.Tables.INSTANCE_VIEW, + new String[] { TaskContract.Instances._ID }, + // find any instance which refers to the given original ID and has the same instance time + // for recurring tasks this matches the INSTANCE_ORIGINAL_TIME, for non-recurring tasks this matches start or due (whichever is present). + String.format("(%1$s == ? or %2$s == ?) and (%3$s == ? or %3$s is null and %4$s == ? or %3$s is null and %4$s is null and %5$s == ?) ", + TaskContract.Instances.TASK_ID, + TaskContract.Instances.ORIGINAL_INSTANCE_ID, + TaskContract.Instances.INSTANCE_ORIGINAL_TIME, + TaskContract.Instances.INSTANCE_START, + TaskContract.Instances.INSTANCE_DUE), + new String[] { + instanceId.value().toString(), + instanceId.value().toString(), + timeStampString, + timeStampString, + timeStampString }, + null, + null, + null)) + { + if (c.getCount() > 0) + { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, "Instance %s of task %d already exists", + entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME).toString(), instanceId.value())); + } + } + } + } + + + private void validateValues(InstanceAdapter instanceAdapter) + { + // actually, no instance value can be changed, the instance table only allows for updating task values + if (new First<>(new Sieved<>(instanceAdapter::isUpdated, INSTANCE_FIELD_ADAPTERS)).isPresent()) + { + throw new IllegalArgumentException("Instance columns are read-only."); + } + + TaskAdapter taskAdapter = instanceAdapter.taskAdapter(); + // By definition, single instances don't have a recurrence set on their own, hence changes to the recurrence fields are not allowed. + if (new First<>(new Sieved<>(taskAdapter::isUpdated, RECURRENCE_FIELD_ADAPTERS)).isPresent()) + { + throw new IllegalArgumentException("Recurrence values can not be modified through the instances table."); + } + } + + + private void validateOriginalInstanceValues(InstanceAdapter instanceAdapter) + { + TaskAdapter taskAdapter = instanceAdapter.taskAdapter(); + // Updates of ORIGINAL_INSTANCE_* fields are not allowed + if (new First<>(new Sieved<>(taskAdapter::isUpdated, ORIGINAL_INSTANCE_FIELD_ADAPTERS)).isPresent()) + { + throw new IllegalArgumentException("ORIGINAL_INSTANCE_* fields can not be updated through the instances table."); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java new file mode 100644 index 0000000..0e33369 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.lists; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.ListAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that performs the actual operations on task lists. + * + * @author Marten Gajda + */ +public final class ListCommitProcessor implements EntityProcessor +{ + + @Override + public ListAdapter insert(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + list.commit(db); + return list; + } + + + @Override + public ListAdapter update(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + list.commit(db); + return list; + } + + + @Override + public void delete(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + db.delete(TaskDatabaseHelper.Tables.LISTS, TaskContract.TaskLists._ID + "=" + list.id(), null); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java new file mode 100644 index 0000000..8b27215 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java @@ -0,0 +1,137 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.lists; + +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import org.dmfs.provider.tasks.model.ListAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; + + +/** + * A processor to validate the values of a task list. + * + * @author Marten Gajda + */ +public final class Validating implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Validating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public ListAdapter insert(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + if (!isSyncAdapter) + { + throw new UnsupportedOperationException("Caller must be a sync adapter to create task lists"); + } + + if (TextUtils.isEmpty(list.valueOf(ListAdapter.ACCOUNT_NAME))) + { + throw new IllegalArgumentException("ACCOUNT_NAME is required on INSERT"); + } + + if (TextUtils.isEmpty(list.valueOf(ListAdapter.ACCOUNT_TYPE))) + { + throw new IllegalArgumentException("ACCOUNT_TYPE is required on INSERT"); + } + + verifyCommon(list, isSyncAdapter); + return mDelegate.insert(db, list, isSyncAdapter); + } + + + @Override + public ListAdapter update(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + if (list.isUpdated(ListAdapter.ACCOUNT_NAME)) + { + throw new IllegalArgumentException("ACCOUNT_NAME is write-once"); + } + + if (list.isUpdated(ListAdapter.ACCOUNT_TYPE)) + { + throw new IllegalArgumentException("ACCOUNT_TYPE is write-once"); + } + + verifyCommon(list, isSyncAdapter); + return mDelegate.update(db, list, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, ListAdapter entityAdapter, boolean isSyncAdapter) + { + if (!isSyncAdapter) + { + throw new UnsupportedOperationException("Caller must be a sync adapter to delete task lists"); + } + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Performs tests that are common to insert an update operations. + * + * @param list + * The {@link ListAdapter} to verify. + * @param isSyncAdapter + * true if the caller is a sync adapter, false otherwise. + */ + private void verifyCommon(ListAdapter list, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (list.isUpdated(ListAdapter._ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (isSyncAdapter) + { + // sync adapters may do all the stuff below + return; + } + + if (list.isUpdated(ListAdapter.LIST_COLOR)) + { + throw new IllegalArgumentException("Only sync adapters can change the LIST_COLOR."); + } + if (list.isUpdated(ListAdapter.LIST_NAME)) + { + throw new IllegalArgumentException("Only sync adapters can change the LIST_NAME."); + } + if (list.isUpdated(ListAdapter.SYNC_ID)) + { + throw new IllegalArgumentException("Only sync adapters can change the _SYNC_ID."); + } + if (list.isUpdated(ListAdapter.SYNC_VERSION)) + { + throw new IllegalArgumentException("Only sync adapters can change SYNC_VERSION."); + } + if (list.isUpdated(ListAdapter.OWNER)) + { + throw new IllegalArgumentException("Only sync adapters can change the list OWNER."); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java new file mode 100644 index 0000000..65bbe9a --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java @@ -0,0 +1,210 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor to adjust some task values automatically. + *

+ * Other then recurrence exceptions no relations are handled by this code. Relation specific changes go to {@link Relating}. + * + * @author Marten Gajda + */ +public final class AutoCompleting implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + private static final String[] TASK_ID_PROJECTION = { TaskContract.Tasks._ID }; + private static final String[] TASK_SYNC_ID_PROJECTION = { TaskContract.Tasks._SYNC_ID }; + + private static final String SYNC_ID_SELECTION = TaskContract.Tasks._SYNC_ID + "=?"; + private static final String TASK_ID_SELECTION = TaskContract.Tasks._ID + "=?"; + + + public AutoCompleting(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + updateFields(db, task, isSyncAdapter); + + if (!isSyncAdapter) + { + // set created date for tasks created on the device + task.set(TaskAdapter.CREATED, DateTime.now()); + } + + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + + if (isSyncAdapter && result.isRecurring()) + { + // task is recurring, update ORIGINAL_INSTANCE_ID of all exceptions that may already exists + ContentValues values = new ContentValues(1); + TaskAdapter.ORIGINAL_INSTANCE_ID.setIn(values, result.id()); + db.update(TaskDatabaseHelper.Tables.TASKS, values, TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID + "=? and " + + TaskContract.Tasks.ORIGINAL_INSTANCE_ID + " is null", new String[] { result.valueOf(TaskAdapter.SYNC_ID) }); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + updateFields(db, task, isSyncAdapter); + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + + if (isSyncAdapter && result.isRecurring() && result.isUpdated(TaskAdapter.SYNC_ID)) + { + // task is recurring, update ORIGINAL_INSTANCE_SYNC_ID of all exceptions that may already exists + ContentValues values = new ContentValues(1); + TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID.setIn(values, result.valueOf(TaskAdapter.SYNC_ID)); + db.update(TaskDatabaseHelper.Tables.TASKS, values, TaskContract.Tasks.ORIGINAL_INSTANCE_ID + "=" + result.id(), null); + } + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + private void updateFields(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + if (!isSyncAdapter) + { + task.set(TaskAdapter._DIRTY, true); + task.set(TaskAdapter.LAST_MODIFIED, DateTime.now()); + + // set proper STATUS if task has been completed + if (task.valueOf(TaskAdapter.COMPLETED) != null && !task.isUpdated(TaskAdapter.STATUS)) + { + task.set(TaskAdapter.STATUS, TaskContract.Tasks.STATUS_COMPLETED); + } + } + + if (task.isUpdated(TaskAdapter.PRIORITY)) + { + Integer priority = task.valueOf(TaskAdapter.PRIORITY); + if (priority != null && priority == 0) + { + // replace priority 0 by null, it's the default and we need that for proper sorting + task.set(TaskAdapter.PRIORITY, null); + } + } + + // Find corresponding ORIGINAL_INSTANCE_ID + if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID)) + { + String[] syncId = { task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) }; + try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_ID_PROJECTION, SYNC_ID_SELECTION, syncId, null, null, null)) + { + if (cursor.moveToNext()) + { + Long originalId = cursor.getLong(0); + task.set(TaskAdapter.ORIGINAL_INSTANCE_ID, originalId); + } + } + } + else if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) // Find corresponding ORIGINAL_INSTANCE_SYNC_ID + { + String[] id = { Long.toString(task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)) }; + try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_SYNC_ID_PROJECTION, TASK_ID_SELECTION, id, null, null, null)) + { + if (cursor.moveToNext()) + { + String originalSyncId = cursor.getString(0); + task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, originalSyncId); + } + } + } + + // check that PERCENT_COMPLETE is an Integer between 0 and 100 if supplied also update status and completed accordingly + if (task.isUpdated(TaskAdapter.PERCENT_COMPLETE)) + { + Integer percent = task.valueOf(TaskAdapter.PERCENT_COMPLETE); + + if (!isSyncAdapter && percent != null && percent == 100) + { + if (!task.isUpdated(TaskAdapter.STATUS)) + { + task.set(TaskAdapter.STATUS, TaskContract.Tasks.STATUS_COMPLETED); + } + + if (!task.isUpdated(TaskAdapter.COMPLETED)) + { + task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); + } + } + else if (!isSyncAdapter && percent != null) + { + if (!task.isUpdated(TaskAdapter.COMPLETED)) + { + task.set(TaskAdapter.COMPLETED, null); + } + } + } + + // validate STATUS and set IS_NEW and IS_CLOSED accordingly + if (task.isUpdated(TaskAdapter.STATUS) || task.id() < 0 /* this is true when the task is new */) + { + Integer status = task.valueOf(TaskAdapter.STATUS); + if (status == null) + { + status = TaskContract.Tasks.STATUS_DEFAULT; + task.set(TaskAdapter.STATUS, status); + } + + task.set(TaskAdapter.IS_NEW, status == TaskContract.Tasks.STATUS_NEEDS_ACTION); + task.set(TaskAdapter.IS_CLOSED, status == TaskContract.Tasks.STATUS_COMPLETED || status == TaskContract.Tasks.STATUS_CANCELLED); + + /* + * Update PERCENT_COMPLETE and COMPLETED (if not given). Sync adapters should know what they're doing, so don't update anything if caller is a sync + * adapter. + */ + if (status == TaskContract.Tasks.STATUS_COMPLETED && !isSyncAdapter) + { + task.set(TaskAdapter.PERCENT_COMPLETE, 100); + if (!task.isUpdated(TaskAdapter.COMPLETED) || task.valueOf(TaskAdapter.COMPLETED) == null) + { + task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); + } + } + else if (!isSyncAdapter) + { + task.set(TaskAdapter.COMPLETED, null); + } + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java new file mode 100644 index 0000000..1891ff3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java @@ -0,0 +1,397 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.jems.function.elementary.DiffMap; +import org.dmfs.jems.iterable.composite.Diff; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.pair.Pair; +import org.dmfs.jems.pair.elementary.RightSidedPair; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.BooleanFieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.InstanceValuesIterable; +import org.dmfs.provider.tasks.utils.Limited; +import org.dmfs.provider.tasks.utils.OverrideValuesFunction; +import org.dmfs.provider.tasks.utils.Range; +import org.dmfs.provider.tasks.utils.RowIterator; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + +import static org.dmfs.provider.tasks.model.TaskAdapter.IS_CLOSED; + + +/** + * A processor that creates or updates the instance values of a task. + * + * @author Marten Gajda + */ +public final class Instantiating implements EntityProcessor +{ + /** + * Projection we use to read the overrides of a task + */ + private final static String[] OVERRIDE_PROJECTION = { + TaskContract.Tasks._ID, + TaskContract.Tasks.DTSTART, + TaskContract.Tasks.DUE, + TaskContract.Tasks.DURATION, + TaskContract.Tasks.TZ, + TaskContract.Tasks.IS_ALLDAY, + TaskContract.Tasks.IS_CLOSED, + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME, + TaskContract.Tasks.ORIGINAL_INSTANCE_ALLDAY }; + + /** + * This is a field adapter for a pseudo column to indicate that the instances may need an update, even if no relevant value has changed. This is useful to + * force an update of the sorting values when the local timezone has been changed. + *

+ * TODO: get rid of it + */ + private final static BooleanFieldAdapter UPDATE_REQUESTED = new BooleanFieldAdapter( + "org.dmfs.tasks.TaskInstanceProcessor.UPDATE_REQUESTED"); + + // for now we only expand the next upcoming instance + private final static int UPCOMING_INSTANCE_COUNT_LIMIT = 1; + + + /** + * Add a pseudo column to the given {@link ContentValues} to request an instances update, even if no time value has changed. + * + * @param values + * The {@link ContentValues} to add the pseudo column to. + */ + public static void addUpdateRequest(ContentValues values) + { + UPDATE_REQUESTED.setIn(values, true); + } + + + private final EntityProcessor mDelegate; + + + public Instantiating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + // an override was created, insert a single task + updateOverrideInstance(db, result, result.id()); + } + else + { + // update the recurring instances, there may already be overrides, so we use the update method + updateMasterInstances(db, result, result.id()); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + // TODO: get rid if this mechanism + boolean updateRequested = task.isUpdated(UPDATE_REQUESTED) ? task.valueOf(UPDATE_REQUESTED) : false; + task.unset(UPDATE_REQUESTED); + + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + + if (!result.isUpdated(TaskAdapter.DTSTART) && !result.isUpdated(TaskAdapter.DUE) && !result.isUpdated(TaskAdapter.DURATION) + && !result.isUpdated(TaskAdapter.STATUS) && !result.isUpdated(TaskAdapter.RDATE) && !result.isUpdated(TaskAdapter.RRULE) && !result.isUpdated( + TaskAdapter.EXDATE) && !result.isUpdated(IS_CLOSED) && !updateRequested) + { + // date values didn't change and update not requested -> no need to update the instances table + return result; + } + if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) == null) + { + updateMasterInstances(db, result, result.id()); + } + else + { + updateOverrideInstance(db, result, result.id()); + } + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + // Note: there is a database trigger which cleans the instances table automatically when a task is deleted + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Update the instance of an override. + *

+ * TODO: take instance overrides into account + * + * @param db + * an {@link SQLiteDatabase}. + * @param taskAdapter + * the {@link TaskAdapter} of the task to insert. + * @param id + * the row id of the new task. + */ + private void updateOverrideInstance(SQLiteDatabase db, TaskAdapter taskAdapter, long id) + { + long origId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + int count = 0; + if (!taskAdapter.isUpdated(IS_CLOSED)) + { + // task status was not updated, we can take the shortcut and only update any existing instance values + for (Single values : new InstanceValuesIterable(id, taskAdapter)) + { + if (count++ > 1) + { + throw new RuntimeException("more than one instance returned for task instance which was supposed to have exactly one"); + } + ContentValues contentValues = values.value(); + // we don't know the current distance, but it for sure hasn't changed either, so just make sure we don't change it + contentValues.remove(TaskContract.Instances.DISTANCE_FROM_CURRENT); + // TASK_ID hasn't changed either + contentValues.remove(TaskContract.Instances.TASK_ID); + + db.update(TaskDatabaseHelper.Tables.INSTANCES, + contentValues, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances.TASK_ID, id), + null); + } + if (count == 0) + { + throw new RuntimeException("no instance returned for task which was supposed to have exactly one"); + } + } + else + { + // task status was updated, this might affect other instances, update them all + // ensure the distance from current is set properly for all sibling instances + try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, + String.format(Locale.ENGLISH, "(%s = %d)", TaskContract.Tasks._ID, origId), null, null, null, null)) + { + if (c.moveToFirst()) + { + TaskAdapter ta = new CursorContentValuesTaskAdapter(c, new ContentValues()); + updateMasterInstances(db, ta, ta.id()); + } + } + } + } + + + /** + * Updates the instances of an existing task + * + * @param db + * An {@link SQLiteDatabase}. + * @param taskAdapter + * the {@link TaskAdapter} of the task to update + * @param id + * the row id of the new task + */ + private void updateMasterInstances(SQLiteDatabase db, TaskAdapter taskAdapter, long id) + { + try (Cursor existingInstances = db.query( + TaskDatabaseHelper.Tables.INSTANCE_VIEW, + new String[] { + TaskContract.Instances._ID, + TaskContract.InstanceColumns.INSTANCE_ORIGINAL_TIME, + TaskContract.InstanceColumns.INSTANCE_START, + TaskContract.InstanceColumns.INSTANCE_START_SORTING, + TaskContract.InstanceColumns.INSTANCE_DUE, + TaskContract.InstanceColumns.INSTANCE_DUE_SORTING, + TaskContract.InstanceColumns.INSTANCE_DURATION, + TaskContract.InstanceColumns.TASK_ID, + TaskContract.InstanceColumns.DISTANCE_FROM_CURRENT, + TaskContract.Instances.IS_CLOSED }, + String.format(Locale.ENGLISH, "%s = ? or %s = ?", TaskContract.Instances.TASK_ID, TaskContract.Instances.ORIGINAL_INSTANCE_ID), + new String[] { Long.toString(id), Long.toString(id) }, + null, + null, + TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + Cursor overrides = db.query( + TaskDatabaseHelper.Tables.TASKS, + OVERRIDE_PROJECTION, + String.format("%s = ? AND %s != 1", TaskContract.Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Tasks._DELETED), + new String[] { Long.toString(id) }, + null, + null, + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME);) + { + + /* + * The goal of the code below is to update existing instances in place (as opposed to delete and recreate all instances). We do this for two reasons: + * 1) efficiency, in most cases existing instances don't change, deleting and recreating them would be overly expensive + * 2) stable row ids, deleting and recreating instances would change their id and void any existing URIs to them + */ + final int idIdx = existingInstances.getColumnIndex(TaskContract.Instances._ID); + final int startIdx = existingInstances.getColumnIndex(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + final int taskIdIdx = existingInstances.getColumnIndex(TaskContract.Instances.TASK_ID); + final int isClosedIdx = existingInstances.getColumnIndex(TaskContract.Instances.IS_CLOSED); + final int distanceIdx = existingInstances.getColumnIndex(TaskContract.Instances.DISTANCE_FROM_CURRENT); + + // get an Iterator of all expected instances + // for very long or even infinite series we need to stop iterating at some point. + + Iterable, Optional>> diff = new Diff<>( + new Mapped<>(Single::value, new Limited<>(10000 /* hard limit for infinite rules*/, + new Mapped<>( + new DiffMap<>( + (original, override) -> override, // we have both, a regular instance and an override -> take the override + original -> original, + override -> override // we only have an override :-o, not really valid but tolerated + ), + new Diff<>( + new InstanceValuesIterable(id, taskAdapter), + new Mapped<>( + cursor -> + new OverrideValuesFunction() + .value(new CursorContentValuesTaskAdapter(cursor, new ContentValues())), + () -> new RowIterator(overrides)), + (left, right) -> { + Long leftLong = left.value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + Long rightLong = right.value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + // null is always smaller + if (leftLong == null) + { + return rightLong == null ? 0 : -1; + } + if (rightLong == null) + { + return 1; + } + + long ldiff = leftLong - rightLong; + return ldiff < 0 ? -1 : (ldiff > 0 ? 1 : 0); + })))), + new Range(existingInstances.getCount()), + (newInstanceValues, cursorRow) -> + { + existingInstances.moveToPosition(cursorRow); + long ldiff = new Backed<>(new NullSafe<>(newInstanceValues.getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME)), 0L).value() + - existingInstances.getLong(startIdx); + return ldiff < 0 ? -1 : (ldiff > 0 ? 1 : 0); + }); + + int distance = -1; + // sync the instances table with the new instances + for (Pair, Optional> next : diff) + { + if (distance >= UPCOMING_INSTANCE_COUNT_LIMIT - 1) + { + // we already expanded enough instances + if (!next.right().isPresent()) + { + // if no further instances exist, stop here + Long original = next.left().value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + if (original != null && existingInstances.moveToLast() && existingInstances.getLong(startIdx) < original) + { + break; + } + + // we may have to delete a few future instances + continue; + } + next = new RightSidedPair<>(next.right()); + } + + if (!next.left().isPresent()) + { + // there is no new instance for this old one, remove it + existingInstances.moveToPosition(next.right().value()); + db.delete(TaskDatabaseHelper.Tables.INSTANCES, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances._ID, existingInstances.getLong(idIdx)), null); + } + else if (!next.right().isPresent()) + { + // there is no old instance for this new one, add it + ContentValues values = next.left().value(); + if (distance >= 0 || values.getAsLong(TaskContract.Instances.DISTANCE_FROM_CURRENT) >= 0) + { + distance += 1; + } + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); + db.insert(TaskDatabaseHelper.Tables.INSTANCES, "", values); + } + else // both sides are present + { + // update this instance + existingInstances.moveToPosition(next.right().value()); + ContentValues values = next.left().value(); + if (distance >= 0 || values.getAsLong(TaskContract.Instances.DISTANCE_FROM_CURRENT) >= 0) + { + // the distance needs to be updated + distance += 1; + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); + } + + ContentValues updates = updatedOnly(values, existingInstances); + if (updates.size() > 0) + { + db.update(TaskDatabaseHelper.Tables.INSTANCES, + updates, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances._ID, existingInstances.getLong(idIdx)), + null); + } + } + } + } + } + + + private static ContentValues updatedOnly(ContentValues newValues, Cursor oldValues) + { + ContentValues result = new ContentValues(newValues); + for (String key : newValues.keySet()) + { + int columnIdx = oldValues.getColumnIndex(key); + if (columnIdx < 0) + { + throw new RuntimeException("Missing column " + key + " in Cursor "); + } + if (oldValues.isNull(columnIdx) && newValues.get(key) == null) + { + result.remove(key); + } + else if (!oldValues.isNull(columnIdx) && newValues.get(key) != null && oldValues.getLong(columnIdx) == newValues.getAsLong(key)) + { + result.remove(key); + } + } + return result; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java new file mode 100644 index 0000000..ea9d9cb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java @@ -0,0 +1,205 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * This processor makes sure that changing the list a task belongs is properly handled by sync adapters. This is achieved by emulating an atomic copy & delete + * operation. + *

+ * TODO: at present we only move recurrence exceptions based on the original row id. We should consider to move exceptions based on the original SYNC_ID as well + * to support moving exception sets of tasks without known master instance. + * + * @author Marten Gajda + */ +public final class Moving implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Moving(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + return mDelegate.insert(db, task, isSyncAdapter); + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + if (isSyncAdapter) + { + // sync-adapters have to implement the move logic themselves + return mDelegate.update(db, task, isSyncAdapter); + } + + if (!task.isUpdated(TaskAdapter.LIST_ID)) + { + // list has not been changed + return mDelegate.update(db, task, isSyncAdapter); + } + + long oldList = task.oldValueOf(TaskAdapter.LIST_ID); + long newList = task.valueOf(TaskAdapter.LIST_ID); + + if (oldList == newList) + { + // list has not been changed + return mDelegate.update(db, task, isSyncAdapter); + } + + Long newMasterId; + Long deletedMasterId = null; + + if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) != null) + { + // this is an exception, move the master first + newMasterId = task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + if (newMasterId != null) + { + // find the master task + Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, TaskContract.Tasks._ID + "=" + newMasterId, null, null, null, null); + try + { + if (c.moveToFirst()) + { + // move the master task + deletedMasterId = moveTask(db, new CursorContentValuesTaskAdapter(c, new ContentValues(16)), oldList, newList, null, true); + } + + } + finally + { + c.close(); + } + } + + // now move this exception, make sure we link the deleted exception to the deleted master + moveTask(db, task, oldList, newList, deletedMasterId, false); + } + else + { + newMasterId = task.id(); + // move the task to the new list + deletedMasterId = moveTask(db, task, oldList, newList, null, false); + } + + if (task.isRecurring() || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + // This task is recurring and may have exceptions or it's an exception itself. Move all (other) exceptions to the new list. + Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, TaskContract.Tasks.ORIGINAL_INSTANCE_ID + "=" + newMasterId + " and " + + TaskContract.Tasks._ID + "!=" + task.id(), null, null, null, null); + try + { + while (c.moveToNext()) + { + moveTask(db, new CursorContentValuesTaskAdapter(c, new ContentValues(16)), oldList, newList, deletedMasterId, true); + } + } + finally + { + c.close(); + } + } + + return mDelegate.update(db, task, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + mDelegate.delete(db, task, isSyncAdapter); + } + + + private Long moveTask(SQLiteDatabase db, TaskAdapter task, long oldList, long newList, Long deletedOriginalId, boolean commitTask) + { + /* + * The task has been moved to a different list. Sync adapters are not expected to support this (especially since the new list may belong to a completely + * different account or even account-type), so we emulate a copy & delete operation. + * + * All sync adapter fields of the task are cleared, so it looks like a new task. In addition we create a new deleted task in the old list having the old + * sync adapter field values. This means that the _ID field of the "deleted" task will not equal the _ID field f the original task. Sync adapters should + * handle that correctly. + */ + + Long result = null; + + // create a deleted task for the old one, unless the task has not been synced yet (which is always true for tasks in the local account) + if (task.valueOf(TaskAdapter.SYNC_ID) != null || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) != null + || task.valueOf(TaskAdapter.SYNC_VERSION) != null) + { + TaskAdapter deletedTask = task.duplicate(); + deletedTask.set(TaskAdapter.LIST_ID, oldList); + deletedTask.set(TaskAdapter.ORIGINAL_INSTANCE_ID, deletedOriginalId); + deletedTask.set(TaskAdapter._DELETED, true); + + // make sure we unset any values that do not exist in the tasks table + deletedTask.unset(TaskAdapter.LIST_COLOR); + deletedTask.unset(TaskAdapter.LIST_NAME); + deletedTask.unset(TaskAdapter.ACCOUNT_NAME); + deletedTask.unset(TaskAdapter.ACCOUNT_TYPE); + deletedTask.unset(TaskAdapter.LIST_OWNER); + deletedTask.unset(TaskAdapter.LIST_ACCESS_LEVEL); + deletedTask.unset(TaskAdapter.LIST_VISIBLE); + + // create the deleted task + deletedTask.commit(db); + + result = deletedTask.id(); + } + + // clear all sync fields to convert the existing task to a new task + task.set(TaskAdapter.LIST_ID, newList); + task.set(TaskAdapter._DIRTY, true); + task.set(TaskAdapter.SYNC1, null); + task.set(TaskAdapter.SYNC2, null); + task.set(TaskAdapter.SYNC3, null); + task.set(TaskAdapter.SYNC4, null); + task.set(TaskAdapter.SYNC5, null); + task.set(TaskAdapter.SYNC6, null); + task.set(TaskAdapter.SYNC7, null); + task.set(TaskAdapter.SYNC8, null); + task.set(TaskAdapter.SYNC_ID, null); + task.set(TaskAdapter.SYNC_VERSION, null); + task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); + if (commitTask) + { + task.commit(db); + } + + return result; + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java new file mode 100644 index 0000000..15e82b4 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java @@ -0,0 +1,77 @@ +/* + * Copyright 2018 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + + +/** + * An {@link EntityProcessor} which updates the {@link TaskContract.Tasks#ORIGINAL_INSTANCE_ID} of any overrides when a master is inserted which has the + * matching {@link TaskContract.Tasks#ORIGINAL_INSTANCE_SYNC_ID}. + * + * @author Marten Gajda + */ +public final class Originating implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Originating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + String syncId = result.valueOf(TaskAdapter.SYNC_ID); + if (syncId != null) + { + // A master task with a syncId has been inserted. + // Update original ID of any existing overrides. + ContentValues values = new ContentValues(1); + values.put(TaskContract.Tasks.ORIGINAL_INSTANCE_ID, result.id()); + db.update(TaskDatabaseHelper.Tables.TASKS, values, String.format(Locale.ENGLISH, "%s = ?", TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID), + new String[] { syncId }); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + return mDelegate.update(db, task, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + mDelegate.delete(db, task, isSyncAdapter); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java new file mode 100644 index 0000000..d641779 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java @@ -0,0 +1,150 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that updates relations for new tasks. + *

+ * In general there is no guarantee that a related task is already in the database when a task is + * inserted. In such a case we can not set the {@link TaskContract.Property.Relation#RELATED_ID} value. This processor updates the {@link + * TaskContract.Property.Relation#RELATED_ID} when a task is inserted. + *

+ * It also updates {@link TaskContract.Property.Relation#RELATED_UID} when a tasks + * is synced the first time and a UID has been set. + *

+ * + * @author Marten Gajda + */ +public final class Relating implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Relating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + // A new task has been inserted by the sync adapter. Update all relations that point to this task. + + if (!isSyncAdapter) + { + // the task was created on the device, so it doesn't have a UID + return result; + } + + String uid = result.valueOf(TaskAdapter._UID); + + if (uid != null) + { + ContentValues v = new ContentValues(1); + v.put(TaskContract.Property.Relation.RELATED_ID, result.id()); + + int updates = db.update(TaskDatabaseHelper.Tables.PROPERTIES, v, + TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_UID + "=?", new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, uid }); + + if (updates > 0) + { + // there were other relations pointing towards this task, update PARENT_IDs if necessary + ContentValues parentIdValues = new ContentValues(1); + parentIdValues.put(TaskContract.Tasks.PARENT_ID, result.id()); + // iterate over all tasks which refer to this as their parent and update their PARENT_ID + try (Cursor c = db.query( + TaskDatabaseHelper.Tables.PROPERTIES, new String[] { TaskContract.Property.Relation.TASK_ID }, + String.format("%s = ? and %s = ? and %s = ?", + TaskContract.Property.Relation.MIMETYPE, + TaskContract.Property.Relation.RELATED_ID, + TaskContract.Property.Relation.RELATED_TYPE), + new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, + String.valueOf(result.id()), + String.valueOf(TaskContract.Property.Relation.RELTYPE_PARENT) }, + null, null, null)) + { + while (c.moveToNext()) + { + db.update(TaskDatabaseHelper.Tables.TASKS, parentIdValues, TaskContract.Tasks._ID + " = ?", new String[] { c.getString(0) }); + } + } + // TODO, way also may have to do this for all the siblings of these tasks. + } + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + // A task has been updated and may have received a UID by the sync adapter. Update all by-id references to this task. + // in this case we don't need to update any PARENT_ID because it should already be set. + + if (!isSyncAdapter) + { + // only sync adapters may assign a UID + return result; + } + + String uid = result.valueOf(TaskAdapter._UID); + + if (uid != null) + { + ContentValues v = new ContentValues(1); + v.put(TaskContract.Property.Relation.RELATED_UID, uid); + + db.update(TaskDatabaseHelper.Tables.PROPERTIES, v, + TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_ID + "=?", new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, Long.toString(result.id()) }); + } + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + mDelegate.delete(db, task, isSyncAdapter); + + if (!isSyncAdapter) + { + // remove once the deletion is final, which is when the sync adapter removes it + return; + } + + db.delete(TaskDatabaseHelper.Tables.PROPERTIES, TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_ID + "=?", + new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, + Long.toString(task.id()) }); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java new file mode 100644 index 0000000..a08f9b7 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java @@ -0,0 +1,119 @@ +/* + * Copyright 2020 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An {@link EntityProcessor} which updates a task's parent-child relations when its {@link TaskContract.Tasks#PARENT_ID} is updated. + * + * @author Marten Gajda + */ +public final class Reparenting implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Reparenting(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, entityAdapter, isSyncAdapter); + if (entityAdapter.isUpdated(TaskAdapter.PARENT_ID)) + { + linkParent(db, result); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + if (entityAdapter.isUpdated(TaskAdapter.PARENT_ID)) + { + unlinkParent(db, entityAdapter); + TaskAdapter result = mDelegate.update(db, entityAdapter, isSyncAdapter); + linkParent(db, entityAdapter); + return result; + } + else + { + return mDelegate.update(db, entityAdapter, isSyncAdapter); + } + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + unlinkParent(db, entityAdapter); + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + private void unlinkParent(SQLiteDatabase db, TaskAdapter taskAdapter) + { + if (taskAdapter.oldValueOf(TaskAdapter.PARENT_ID) != null) + { + // delete any parent, child or sibling relation with this task + db.delete(TaskDatabaseHelper.Tables.PROPERTIES, + String.format("%s = ? AND (%s = ? and %s in (?, ?) or %s = ? and %s in (?, ?))", + TaskContract.Property.Relation.MIMETYPE, + TaskContract.Property.Relation.TASK_ID, + TaskContract.Property.Relation.RELATED_TYPE, + TaskContract.Property.Relation.RELATED_ID, + TaskContract.Property.Relation.RELATED_TYPE), + new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, + String.valueOf(taskAdapter.valueOf(TaskAdapter._ID)), + String.valueOf(TaskContract.Property.Relation.RELTYPE_SIBLING), + String.valueOf(TaskContract.Property.Relation.RELTYPE_PARENT), + String.valueOf(taskAdapter.valueOf(TaskAdapter._ID)), + String.valueOf(TaskContract.Property.Relation.RELTYPE_SIBLING), + String.valueOf(TaskContract.Property.Relation.RELTYPE_CHILD) }); + } + } + + + private void linkParent(SQLiteDatabase db, TaskAdapter taskAdapter) + { + if (taskAdapter.valueOf(TaskAdapter.PARENT_ID) != null) + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Property.Relation.MIMETYPE, TaskContract.Property.Relation.CONTENT_ITEM_TYPE); + values.put(TaskContract.Property.Relation.TASK_ID, taskAdapter.id()); + values.put(TaskContract.Property.Relation.RELATED_TYPE, TaskContract.Property.Relation.RELTYPE_PARENT); + values.put(TaskContract.Property.Relation.RELATED_ID, taskAdapter.valueOf(TaskAdapter.PARENT_ID)); + values.put(TaskContract.Property.Relation.RELATED_UID, taskAdapter.valueOf(TaskAdapter._UID)); + db.insert(TaskDatabaseHelper.Tables.PROPERTIES, "", values); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java new file mode 100644 index 0000000..5b62b8e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.FTSDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.Profiled; + + +/** + * An {@link EntityProcessor} to update the fast text search table when inserting or updating a task. + * + * @author Marten Gajda + */ +public final class Searchable implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Searchable(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + new Profiled("InsertFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + new Profiled("UpdateFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + new Profiled("DeleteFTS").run(() -> mDelegate.delete(db, entityAdapter, isSyncAdapter)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java new file mode 100644 index 0000000..677dd61 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that performs the actual operations on tasks. + * + * @author Marten Gajda + */ +public final class TaskCommitProcessor implements EntityProcessor +{ + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + task.commit(db); + return task; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + task.commit(db); + return task; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + String accountType = task.valueOf(TaskAdapter.ACCOUNT_TYPE); + + if (isSyncAdapter || TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType)) + { + // this is a local task or it's removed by a sync adapter, in either case we delete it right away + db.delete(TaskDatabaseHelper.Tables.TASKS, TaskContract.TaskColumns._ID + "=" + task.id(), null); + } + else + { + // just set the deleted flag otherwise + task.set(TaskAdapter._DELETED, true); + task.commit(db); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java new file mode 100644 index 0000000..a28a97e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java @@ -0,0 +1,278 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.Duration; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that validates the values of a task. + * + * @author Marten Gajda + */ +public final class Validating implements EntityProcessor +{ + private static final String[] TASKLIST_ID_PROJECTION = { TaskContract.TaskLists._ID }; + private static final String TASKLISTS_ID_SELECTION = TaskContract.TaskLists._ID + "="; + + private final EntityProcessor mDelegate; + + + public Validating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + verifyCommon(task, isSyncAdapter); + + // LIST_ID must be present and refer to an existing TaskList row id + Long listId = task.valueOf(TaskAdapter.LIST_ID); + if (listId == null) + { + throw new IllegalArgumentException("LIST_ID is required on INSERT"); + } + + // TODO: get rid of this query and use a cache instead + // TODO: ensure that the list is writable unless the caller is a sync adapter + Cursor cursor = db.query(TaskDatabaseHelper.Tables.LISTS, TASKLIST_ID_PROJECTION, TASKLISTS_ID_SELECTION + listId, null, null, null, null); + try + { + if (cursor == null || cursor.getCount() != 1) + { + throw new IllegalArgumentException("LIST_ID must refer to an existing TaskList"); + } + } + finally + { + if (cursor != null) + { + cursor.close(); + } + } + return mDelegate.insert(db, task, isSyncAdapter); + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + verifyCommon(task, isSyncAdapter); + + // only sync adapters can modify original sync id and original instance id of an existing task + if (!isSyncAdapter && (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID) || task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID))) + { + throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID can be modified by sync adapters only"); + } + + // only sync adapters are allowed to change the UID of existing tasks + if (!isSyncAdapter && task.isUpdated(TaskAdapter._UID)) + { + throw new IllegalArgumentException("modification of _UID is not allowed to non-sync adapters"); + } + + return mDelegate.update(db, task, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Performs tests that are common to insert an update operations. + * + * @param task + * The {@link TaskAdapter} to verify. + * @param isSyncAdapter + * true if the caller is a sync adapter, false otherwise. + */ + private void verifyCommon(TaskAdapter task, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (task.isUpdated(TaskAdapter._ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (task.isUpdated(TaskAdapter.VERSION)) + { + throw new IllegalArgumentException("VERSION can not be set manually"); + } + + // account name can not be set on a tasks + if (task.isUpdated(TaskAdapter.ACCOUNT_NAME)) + { + throw new IllegalArgumentException("ACCOUNT_NAME can not be set on a tasks"); + } + + // account type can not be set on a tasks + if (task.isUpdated(TaskAdapter.ACCOUNT_TYPE)) + { + throw new IllegalArgumentException("ACCOUNT_TYPE can not be set on a tasks"); + } + + // list color is read only for tasks + if (task.isUpdated(TaskAdapter.LIST_COLOR)) + { + throw new IllegalArgumentException("LIST_COLOR can not be set on a tasks"); + } + + // no one can undelete a task! + if (task.isUpdated(TaskAdapter._DELETED)) + { + throw new IllegalArgumentException("modification of _DELETE is not allowed"); + } + + // only sync adapters are allowed to remove the dirty flag + if (!isSyncAdapter && task.isUpdated(TaskAdapter._DIRTY)) + { + throw new IllegalArgumentException("modification of _DIRTY is not allowed"); + } + + // only sync adapters are allowed to set creation time + if (!isSyncAdapter && task.isUpdated(TaskAdapter.CREATED)) + { + throw new IllegalArgumentException("modification of CREATED is not allowed"); + } + + // IS_NEW is set automatically + if (task.isUpdated(TaskAdapter.IS_NEW)) + { + throw new IllegalArgumentException("modification of IS_NEW is not allowed"); + } + + // IS_CLOSED is set automatically + if (task.isUpdated(TaskAdapter.IS_CLOSED)) + { + throw new IllegalArgumentException("modification of IS_CLOSED is not allowed"); + } + + // HAS_PROPERTIES is set automatically + if (task.isUpdated(TaskAdapter.HAS_PROPERTIES)) + { + throw new IllegalArgumentException("modification of HAS_PROPERTIES is not allowed"); + } + + // HAS_ALARMS is set automatically + if (task.isUpdated(TaskAdapter.HAS_ALARMS)) + { + throw new IllegalArgumentException("modification of HAS_ALARMS is not allowed"); + } + + // only sync adapters are allowed to set modification time + if (!isSyncAdapter && task.isUpdated(TaskAdapter.LAST_MODIFIED)) + { + throw new IllegalArgumentException("modification of MODIFICATION_TIME is not allowed"); + } + + if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) && task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) + { + throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID must not be specified at the same time"); + } + + // check that CLASSIFICATION is an Integer between 0 and 2 if given + if (task.isUpdated(TaskAdapter.CLASSIFICATION)) + { + Integer classification = task.valueOf(TaskAdapter.CLASSIFICATION); + if (classification != null && (classification < 0 || classification > 2)) + { + throw new IllegalArgumentException("CLASSIFICATION must be an integer between 0 and 2"); + } + } + + // check that PRIORITY is an Integer between 0 and 9 if given + if (task.isUpdated(TaskAdapter.PRIORITY)) + { + Integer priority = task.valueOf(TaskAdapter.PRIORITY); + if (priority != null && (priority < 0 || priority > 9)) + { + throw new IllegalArgumentException("PRIORITY must be an integer between 0 and 9"); + } + } + + // check that PERCENT_COMPLETE is an Integer between 0 and 100 + if (task.isUpdated(TaskAdapter.PERCENT_COMPLETE)) + { + Integer percent = task.valueOf(TaskAdapter.PERCENT_COMPLETE); + if (percent != null && (percent < 0 || percent > 100)) + { + throw new IllegalArgumentException("PERCENT_COMPLETE must be null or an integer between 0 and 100"); + } + } + + // validate STATUS + if (task.isUpdated(TaskAdapter.STATUS)) + { + Integer status = task.valueOf(TaskAdapter.STATUS); + if (status != null && (status < TaskContract.Tasks.STATUS_NEEDS_ACTION || status > TaskContract.Tasks.STATUS_CANCELLED)) + { + throw new IllegalArgumentException("invalid STATUS: " + status); + } + } + + // ensure that DUE and DURATION are set properly if DTSTART is given + Long dtStart = task.valueOf(TaskAdapter.DTSTART_RAW); + Long due = task.valueOf(TaskAdapter.DUE_RAW); + Duration duration = task.valueOf(TaskAdapter.DURATION); + + if (dtStart != null) + { + if (due != null && duration != null) + { + throw new IllegalArgumentException("Only one of DUE or DURATION must be supplied."); + } + else if (due != null) + { + if (due < dtStart) + { + throw new IllegalArgumentException("DUE must not be < DTSTART"); + } + } + else if (duration != null) + { + if (duration.getSign() == -1) + { + throw new IllegalArgumentException("DURATION must not be negative"); + } + } + } + else if (duration != null) + { + throw new IllegalArgumentException("DURATION must not be supplied without DTSTART"); + } + + // if one of DTSTART or DUE is given, TZ must not be null unless it's an all-day task + if ((dtStart != null || due != null) && !task.valueOf(TaskAdapter.IS_ALLDAY) && task.valueOf(TaskAdapter.TIMEZONE_RAW) == null) + { + throw new IllegalArgumentException("TIMEZONE must be supplied if one of DTSTART or DUE is not null and not all-day"); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java new file mode 100644 index 0000000..c9e606c --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.provider.tasks.utils.Zipped; +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * A {@link Single} of date and time {@link ContentValues} of an instance. + * + * @author Marten Gajda + */ +public final class Dated extends DelegatingSingle +{ + + public Dated(Optional dateTime, String timeStampColumn, String sortingColumn, Single delegate) + { + super(new Zipped<>( + dateTime, + delegate, + (dateTime1, values) -> + { + // add timestamp and sorting + values.put(timeStampColumn, dateTime1.getTimestamp()); + values.put(sortingColumn, dateTime1.isAllDay() ? dateTime1.getInstance() : dateTime1.shiftTimeZone(TimeZone.getDefault()).getInstance()); + return values; + })); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java new file mode 100644 index 0000000..fafc5ca --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link Single} of the instance distance {@link ContentValues} of an instance. + * + * @author Marten Gajda + */ +public final class Distant extends DelegatingSingle +{ + + public Distant(int distance, Single delegate) + { + super(() -> + { + ContentValues values = delegate.value(); + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); + return values; + }); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java new file mode 100644 index 0000000..d39005f --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator to a {@link Single} of {@link ContentValues} adding due data. + * + * @author Marten Gajda + */ +public final class DueDated extends DelegatingSingle +{ + public DueDated(Optional due, Single delegate) + { + super(new Dated(due, TaskContract.Instances.INSTANCE_DUE, TaskContract.Instances.INSTANCE_DUE_SORTING, delegate)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java new file mode 100644 index 0000000..0552a87 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator for {@link Single}s of Instance {@link ContentValues} which populates the {@link TaskContract.Instances#INSTANCE_DURATION} field based on the + * already populated {@link TaskContract.Instances#INSTANCE_START} and {@link TaskContract.Instances#INSTANCE_DUE} fields. + * + * @author Marten Gajda + */ +public final class Enduring implements Single +{ + private final Single mDelegate; + + + public Enduring(Single delegate) + { + mDelegate = delegate; + } + + + @Override + public ContentValues value() + { + ContentValues values = mDelegate.value(); + // just store the difference between due and start, if both are present, otherwise store null + values.put(TaskContract.Instances.INSTANCE_DURATION, + new Backed( + new Zipped<>( + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)), + (start, due) -> due - start), + () -> null).value()); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java new file mode 100644 index 0000000..793ffa1 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.procedure.composite.ForEach; +import org.dmfs.jems.single.Single; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator for {@link Single}s of Instance {@link ContentValues} which populates the {@link TaskContract.Instances#INSTANCE_ORIGINAL_TIME} field based on + * the given {@link Optional} original start. + * + * @author Marten Gajda + */ +public final class Overridden implements Single +{ + private final Optional mOriginalTime; + private final Single mDelegate; + + + public Overridden(DateTime originalTime, ContentValues delegate) + { + this(new Present<>(originalTime), () -> delegate); + } + + + public Overridden(Optional originalTime, Single delegate) + { + mOriginalTime = originalTime; + mDelegate = delegate; + } + + + @Override + public ContentValues value() + { + ContentValues values = mDelegate.value(); + new ForEach<>(new Mapped<>(DateTime::getTimestamp, mOriginalTime)).process(time -> values.put(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, time)); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java new file mode 100644 index 0000000..5ecb320 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator to a {@link Single} of {@link ContentValues} adding start data. + * + * @author Marten Gajda + */ +public final class StartDated extends DelegatingSingle +{ + public StartDated(Optional start, Single delegate) + { + super(new Dated(start, TaskContract.Instances.INSTANCE_START, TaskContract.Instances.INSTANCE_START_SORTING, delegate)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java new file mode 100644 index 0000000..65be8e9 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator to {@link Single}s of {@link ContentValues} adding a {@link TaskContract.Instances#TASK_ID} to the data. + * + * @author Marten Gajda + */ +public final class TaskRelated implements Single +{ + private final long mTaskId; + private final Single mDelegate; + + + public TaskRelated(TaskAdapter taskAdapter, Single delegate) + { + this(taskAdapter.id(), delegate); + } + + + public TaskRelated(long taskId, Single delegate) + { + mTaskId = taskId; + mDelegate = delegate; + } + + + @Override + public ContentValues value() + { + ContentValues values = mDelegate.value(); + values.put(TaskContract.Instances.TASK_ID, mTaskId); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java new file mode 100644 index 0000000..b46d3cc --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.single.Single; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link Single} of instance data {@link ContentValues}. It initializes most columns with {@code null} values, except for {@link + * TaskContract.Instances#TASK_ID} which is left out and {@link TaskContract.Instances#DISTANCE_FROM_CURRENT} which is initialized with {@code 0} as well. + * + * @author Marten Gajda + */ +public final class VanillaInstanceData implements Single +{ + @Override + public ContentValues value() + { + ContentValues values = new ContentValues(10); + values.putNull(TaskContract.Instances.INSTANCE_START); + values.putNull(TaskContract.Instances.INSTANCE_START_SORTING); + values.putNull(TaskContract.Instances.INSTANCE_DUE); + values.putNull(TaskContract.Instances.INSTANCE_DUE_SORTING); + values.putNull(TaskContract.Instances.INSTANCE_DURATION); + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, 0); + values.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java new file mode 100644 index 0000000..3f37b2e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.jems.predicate.Predicate; + +import java.util.Arrays; + + +/** + * A {@link Predicate} which determines whether all values of a ContentValues object are present in a {@link Cursor}. + * + * @author Marten Gajda + */ +public final class ContainsValues implements Predicate +{ + private final ContentValues mValues; + + + public ContainsValues(ContentValues values) + { + mValues = values; + } + + + @Override + public boolean satisfiedBy(Cursor testedInstance) + { + for (String key : mValues.keySet()) + { + int columnIdx = testedInstance.getColumnIndex(key); + if (columnIdx < 0) + { + return false; + } + + if (testedInstance.getType(columnIdx) == Cursor.FIELD_TYPE_BLOB) + { + if (!Arrays.equals(mValues.getAsByteArray(key), testedInstance.getBlob(columnIdx))) + { + return false; + } + } + else + { + String stringValue = mValues.getAsString(key); + if (stringValue != null && !stringValue.equals(testedInstance.getString(columnIdx)) || stringValue == null && !testedInstance.isNull(columnIdx)) + { + return false; + } + } + } + return true; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java new file mode 100644 index 0000000..5ee653d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java @@ -0,0 +1,120 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.dmfs.iterators.SingletonIterator; +import org.dmfs.jems.iterable.elementary.Seq; +import org.dmfs.jems.iterator.decorators.Mapped; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; +import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Enduring; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Overridden; +import org.dmfs.provider.tasks.processors.tasks.instancedata.StartDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.TaskRelated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.VanillaInstanceData; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.Duration; + +import java.util.Iterator; + + +/** + * An {@link Iterable} of {@link Single} {@link ContentValues} of the instances of a task. + * + * @author Marten Gajda + */ +// TODO: replace Single with Generator +public final class InstanceValuesIterable implements Iterable> +{ + private final long mId; + private final TaskAdapter mTaskAdapter; + + + public InstanceValuesIterable(long id, TaskAdapter taskAdapter) + { + mId = id; + mTaskAdapter = taskAdapter; + } + + + @Override + public Iterator> iterator() + { + Optional start = new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DTSTART)); + // effective due is either the actual due, start + duration or absent + Optional effectiveDue = new FirstPresent<>( + new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DUE)), + new Zipped<>(start, new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DURATION)), DateTime::addDuration)); + + Single baseData = new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Enduring(new DueDated(effectiveDue, new StartDated(start, new TaskRelated(mId, new VanillaInstanceData()))))); + + if (!mTaskAdapter.isRecurring()) + { + return new SingletonIterator<>( + // apply the Overridden decorator only if this task has an ORIGINAL_INSTANCE_TIME + new org.dmfs.provider.tasks.utils.Zipped<>( + new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)), + baseData, + (DateTime time, ContentValues data) -> new Overridden(time, data).value())); + } + + if (start.isPresent()) + { + Optional effectiveDuration = new FirstPresent<>( + new Seq<>( + new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DURATION)), + new Zipped<>(start, effectiveDue, + (dtStart, due) -> new Duration(1, 0, (int) ((due.getTimestamp() - dtStart.getTimestamp()) / 1000))))); + + return new Mapped<>(dateTime -> new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Overridden(new Present<>(dateTime), + new Enduring( + new DueDated(new Zipped<>(new Present<>(dateTime), effectiveDuration, this::addDuration), + new StartDated(new Present<>(dateTime), + new TaskRelated(mId, new VanillaInstanceData())))))), + new TaskInstanceIterable(mTaskAdapter).iterator()); + } + + // special treatment for recurring tasks without a DTSTART: + return new Mapped<>(dateTime -> new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Overridden(new Present<>(dateTime), + new DueDated(new Present<>(dateTime), new TaskRelated(mId, new VanillaInstanceData())))), + new TaskInstanceIterable(mTaskAdapter).iterator()); + + } + + + private DateTime addDuration(DateTime dt, Duration dur) + { + if (dt.isAllDay() && dur.getSecondsOfDay() != 0) + { + dur = new Duration(1, dur.getWeeks() * 7 + dur.getDays() + dur.getSecondsOfDay() / (3600 * 24), 0); + } + return dt.addDuration(dur); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java new file mode 100644 index 0000000..43833a1 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java @@ -0,0 +1,49 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import java.util.Iterator; + + +/** + * An {@link Iterable} which limits the number of elements. + *

+ * TODO: move to jems + * + * @author Marten Gajda + * @deprecated + */ +@Deprecated +public final class Limited implements Iterable +{ + private final int mCount; + private final Iterable mDelegate; + + + public Limited(int count, Iterable delegate) + { + mCount = count; + mDelegate = delegate; + } + + + @Override + public Iterator iterator() + { + return new LimitedIterator<>(mCount, mDelegate.iterator()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java new file mode 100644 index 0000000..88bba90 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.iterators.AbstractBaseIterator; + +import java.util.Iterator; +import java.util.NoSuchElementException; + + +/** + * An {@link Iterator} which limits the number elements. + * TODO: move to jems + * + * @author Marten Gajda + * @deprecated + */ +@Deprecated +public final class LimitedIterator extends AbstractBaseIterator +{ + private int mCount; + private final Iterator mDelegate; + + + public LimitedIterator(int count, Iterator delegate) + { + mCount = count; + mDelegate = delegate; + } + + + @Override + public boolean hasNext() + { + return mCount > 0 && mDelegate.hasNext(); + } + + + @Override + public T next() + { + if (!hasNext()) + { + throw new NoSuchElementException("No more elements to iterate"); + } + mCount--; + return mDelegate.next(); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java new file mode 100644 index 0000000..b8984b6 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.dmfs.jems.function.Function; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; +import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Enduring; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Overridden; +import org.dmfs.provider.tasks.processors.tasks.instancedata.StartDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.TaskRelated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.VanillaInstanceData; +import org.dmfs.rfc5545.DateTime; + + +/** + * An {@link Iterable} of {@link Single} {@link ContentValues} of the overrides of a task. + * + * @author Marten Gajda + */ +public final class OverrideValuesFunction implements Function> +{ + + @Override + public Single value(TaskAdapter taskAdapter) + { + Optional start = new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DTSTART)); + // effective due is either the actual due, start + duration or absent + Optional effectiveDue = new FirstPresent<>( + new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DUE)), + new Zipped<>(start, new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DURATION)), DateTime::addDuration)); + + Single baseData = new Distant(taskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Enduring(new DueDated(effectiveDue, new StartDated(start, new TaskRelated(taskAdapter, new VanillaInstanceData()))))); + + // apply the Overridden decorator only if this task has an ORIGINAL_INSTANCE_TIME + return new org.dmfs.provider.tasks.utils.Zipped<>( + new NullSafe<>(taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)), + baseData, + (DateTime time, ContentValues data) -> new Overridden(time, data).value()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java new file mode 100644 index 0000000..a30f3fc --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java @@ -0,0 +1,80 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.util.Log; + +import org.dmfs.jems.fragile.Fragile; +import org.dmfs.jems.single.Single; + +import java.util.Locale; + + +/** + * A simple class to measure the execution time of a given piece of code. + * + * @author Marten Gajda + */ +public final class Profiled +{ + private final String mSubject; + + + public Profiled(String subject) + { + mSubject = subject; + } + + + public void run(Runnable runnable) + { + long start = System.currentTimeMillis(); + runnable.run(); + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + + + public V run(Single runnable) + { + + long start = System.currentTimeMillis(); + try + { + return runnable.value(); + } + finally + { + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + } + + + public V run(Fragile runnable) throws E + { + + long start = System.currentTimeMillis(); + try + { + return runnable.value(); + } + finally + { + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java new file mode 100644 index 0000000..731e424 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.iterator.generators.IntSequenceGenerator; + +import java.util.Iterator; + + +/** + * An {@link Iterable} which iterates a range of numbers. + *

+ * TODO: implement in jems + * + * @author Marten Gajda + */ +@Deprecated +public final class Range implements Iterable +{ + private final int mStart; + private final int mEnd; + + + public Range(int end) + { + this(0, end); + } + + + public Range(int start, int end) + { + mStart = start; + mEnd = end; + } + + + @Override + public Iterator iterator() + { + return new LimitedIterator<>(mEnd - mStart, new IntSequenceGenerator(mStart)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java new file mode 100644 index 0000000..e5b5fab --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java @@ -0,0 +1,49 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.Context; + +import org.dmfs.iterators.elementary.Seq; + +import java.util.Iterator; + + +/** + * An {@link Iterable} of a string array resource. + * + * @author Marten Gajda + */ +public final class ResourceArray implements Iterable +{ + private final Context mContext; + private final int mResource; + + + public ResourceArray(Context context, int resource) + { + mContext = context; + mResource = resource; + } + + + @Override + public Iterator iterator() + { + return new Seq<>(mContext.getResources().getStringArray(mResource)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java new file mode 100644 index 0000000..e49ac38 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.database.Cursor; + +import org.dmfs.iterators.AbstractBaseIterator; + +import java.util.NoSuchElementException; + + +/** + * @author Marten Gajda + */ +public final class RowIterator extends AbstractBaseIterator +{ + private final Cursor mCursor; + + + public RowIterator(Cursor cursor) + { + mCursor = cursor; + } + + + @Override + public boolean hasNext() + { + return mCursor.getCount() > 0 && !mCursor.isClosed() && !mCursor.isLast(); + } + + + @Override + public Cursor next() + { + if (!hasNext()) + { + throw new NoSuchElementException("No other rows to iterate."); + } + mCursor.moveToNext(); + return mCursor; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java new file mode 100644 index 0000000..d96ac73 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.jems.function.Function; + +import java.util.LinkedList; +import java.util.List; + + +/** + * A {@link Function} which returns all column names of a specific table on a given database. + * + * @author Marten Gajda + */ +public final class TableColumns implements Function> +{ + private final String mTableName; + + + public TableColumns(String tableName) + { + mTableName = tableName; + } + + + @Override + public Iterable value(SQLiteDatabase db) + { + try (Cursor cursor = db.rawQuery(String.format("PRAGMA table_info(%s)", DatabaseUtils.sqlEscapeString(mTableName)), null)) + { + int nameIdx = cursor.getColumnIndexOrThrow("name"); + + List result = new LinkedList<>(); + while (cursor.moveToNext()) + { + result.add(cursor.getString(nameIdx)); + } + + return result; + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java new file mode 100644 index 0000000..b3620b3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceList; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; + +import java.util.Iterator; +import java.util.TimeZone; + + +/** + * An {@link Iterable} of all the instances of a task. + * + * @author Marten Gajda + */ +public final class TaskInstanceIterable implements Iterable +{ + private final TaskAdapter mTaskAdapter; + + + public TaskInstanceIterable(TaskAdapter taskAdapter) + { + mTaskAdapter = taskAdapter; + } + + + @Override + public Iterator iterator() + { + DateTime dtstart = new Backed(new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DTSTART)), () -> mTaskAdapter.valueOf(TaskAdapter.DUE)).value(); + + RecurrenceSet set = new RecurrenceSet(); + RecurrenceRule rule = mTaskAdapter.valueOf(TaskAdapter.RRULE); + if (rule != null) + { + if (rule.getUntil() != null && dtstart.isFloating() != rule.getUntil().isFloating()) + { + // rule UNTIL date mismatches start. This is merely a workaround for existing users. In future we should make sure + // such tasks don't exist + if (dtstart.isFloating()) + { + // make until floating too by making it floating in the current time zone + rule.setUntil(rule.getUntil().shiftTimeZone(TimeZone.getDefault()).swapTimeZone(null)); + } + else + { + // anchor UNTIL in the current time zone + rule.setUntil(new DateTime(null, rule.getUntil().getTimestamp()).swapTimeZone(TimeZone.getDefault())); + } + } + set.addInstances(new RecurrenceRuleAdapter(rule)); + } + + set.addInstances(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.RDATE)).value())); + set.addExceptions(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.EXDATE)).value())); + + return new TaskInstanceIterator(dtstart, set); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java new file mode 100644 index 0000000..5b74cfb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.iterators.AbstractBaseIterator; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.TimeZone; + + +/** + * An {@link Iterator} of instances as returned by a {@link RecurrenceSetIterator}. + *

+ * TODO: this should go to lib-recur + * + * @author Marten Gajda + */ +public final class TaskInstanceIterator extends AbstractBaseIterator +{ + private final DateTime mStart; + private final RecurrenceSetIterator mSetIterator; + private final String mTimezone; + + + TaskInstanceIterator(DateTime start, RecurrenceSet set) + { + this(start, set.iterator(start.getTimeZone(), start.getTimestamp()), + new Backed<>(new Mapped<>(TimeZone::getID, new NullSafe<>(start.getTimeZone())), () -> null).value()); + } + + + TaskInstanceIterator(DateTime start, RecurrenceSetIterator setIterator, String timezone) + { + mStart = start; + mSetIterator = setIterator; + mTimezone = timezone; + } + + + @Override + public boolean hasNext() + { + return mSetIterator.hasNext(); + } + + + @Override + public DateTime next() + { + if (!hasNext()) + { + throw new NoSuchElementException("No more elements to iterate"); + } + DateTime result = new DateTime(mStart.getTimeZone(), mSetIterator.next()); + return mStart.isAllDay() ? result.toAllDay() : mTimezone == null ? result.swapTimeZone(null) : result; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java new file mode 100644 index 0000000..84f0949 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java @@ -0,0 +1,55 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.single.Single; +import org.dmfs.rfc5545.DateTime; + + +/** + * A {@link Single} of an array of timestamp values of a given {@link Iterable} of {@link DateTime}s. + * + * @author Marten Gajda + */ +public final class Timestamps implements Single +{ + private final Iterable mDateTimes; + + + public Timestamps(Iterable dateTimes) + { + mDateTimes = dateTimes; + } + + + @Override + public long[] value() + { + int count = 0; + for (DateTime ignored : mDateTimes) + { + count += 1; + } + long[] timeStamps = new long[count]; + int i = 0; + for (DateTime dt : mDateTimes) + { + timeStamps[i++] = dt.getTimestamp(); + } + return timeStamps; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java new file mode 100644 index 0000000..7ae6b44 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.SinglePresent; +import org.dmfs.jems.procedure.Procedure; +import org.dmfs.jems.single.Single; + + +/** + * Experiemental Procedure which calls another procedure with a given value. + *

+ * TODO move to jems if this works out well + * + * @author Marten Gajda + */ +@Deprecated +public final class With implements Procedure> +{ + private final Optional mValue; + + + public With(T value) + { + this(() -> value); + } + + + public With(Single value) + { + this(new SinglePresent<>(value)); + } + + + public With(Optional value) + { + mValue = value; + } + + + @Override + public void process(Procedure delegate) + { + if (mValue.isPresent()) + { + delegate.process(mValue.value()); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java new file mode 100644 index 0000000..80df3f7 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.jems.single.decorators.DelegatingSingle; + + +/** + * Experimental {@link Single} which applies a {@link BiFunction} based on the presence of an {@link Optional}. + *

+ * TODO: maybe a more appropriate name? + *

+ * TODO: move to jems + * + * @author Marten Gajda + */ +@Deprecated +public final class Zipped extends DelegatingSingle +{ + public Zipped(Optional optionalValue, Single delegate, BiFunction function) + { + super(new Backed(new Mapped<>(from -> function.value(from, delegate.value()), optionalValue), delegate)); + } +} diff --git a/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java b/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java new file mode 100644 index 0000000..3ce329d --- /dev/null +++ b/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java @@ -0,0 +1,1728 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.contract; + +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.SyncStateContract; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + + +/** + * Task contract. This class defines the interface to the task provider. + *

+ * TODO: Add missing javadoc. + *

+ *

+ * TODO: Specify extended properties + *

+ *

+ * TODO: Add CONTENT_URI for the attachment store. + *

+ *

+ * TODO: Also, we could use some refactoring... + *

+ * + * @author Marten Gajda + * @author Tobias Reinsch + */ +public final class TaskContract +{ + + private static Map sUriFactories = new HashMap(4); + + /** + * URI parameter to signal that the caller is a sync adapter. + */ + public static final String CALLER_IS_SYNCADAPTER = "caller_is_syncadapter"; + + /** + * URI parameter to signal the request of the extended properties of a task. + */ + public static final String LOAD_PROPERTIES = "load_properties"; + + /** + * URI parameter to submit the account name of the account we operate on. + */ + public static final String ACCOUNT_NAME = "account_name"; + + /** + * URI parameter to submit the account type of the account we operate on. + */ + public static final String ACCOUNT_TYPE = "account_type"; + + /** + * Account name for local, unsynced task lists. + */ + public static final String LOCAL_ACCOUNT_NAME = "Local"; + + /** + * Account type for local, unsynced task lists. + */ + public static final String LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"; + + /** + * Broadcast action that's sent when the task database has been initialized, either because the app was launched for the first time or because the app was + * launched after the user cleared the app data. + *

+ * The intent data represents the authority of the provider, the MIME type will be {@link #MIMETYPE_AUTHORITY}. + */ + public static final String ACTION_DATABASE_INITIALIZED = "org.dmfs.tasks.DATABASE_INITIALIZED"; + + /** + * A MIME type of an authority. Authorities itself don't seem to have a MIME type in Android, so we just use our own. + */ + public static final String MIMETYPE_AUTHORITY = ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.org.dmfs.authority.mimetype"; + + /** + * The action of the broadcast that's send when a task becomes due. The intent data will be a {@link Uri} of the task that became due. + */ + public static final String ACTION_BROADCAST_TASK_DUE = "org.dmfs.android.tasks.TASK_DUE"; + + /** + * The action of the broadcast that's send when a task starts. The intent data will be a {@link Uri} of the task that has started. + */ + public static final String ACTION_BROADCAST_TASK_STARTING = "org.dmfs.android.tasks.TASK_START"; + + /** + * A Long extra that contains a timestamp of the event that's triggered. So this is either the timestamp of the start or due date of the task. + */ + public final static String EXTRA_TASK_TIMESTAMP = "org.dmfs.provider.tasks.extra.TIMESTAMP"; + + /** + * A Boolean extra to indicate that the event that was triggered is an all-day date. + */ + public final static String EXTRA_TASK_ALLDAY = "org.dmfs.provider.tasks.extra.ALLDAY"; + + /** + * A String extra containing the timezone id of the task. + */ + public final static String EXTRA_TASK_TIMEZONE = "org.dmfs.provider.tasks.extra.TIMEZONE"; + + /** + * A String extra containing the title of the task. + */ + public final static String EXTRA_TASK_TITLE = "org.dmfs.provider.tasks.extra.TITLE"; + + /** + * The name of the {@link Intent#ACTION_PROVIDER_CHANGED} extra that contains the {@link ArrayList} of {@link Uri}s that have been modified. This always + * goes along with an {@link #EXTRA_OPERATIONS} which contains a code for the operation executed on a Uri at the same index. + */ + public final static String EXTRA_OPERATIONS_URIS = "org.dmfs.tasks.OPERATIONS_URIS"; + + /** + * The name of the {@link Intent#ACTION_PROVIDER_CHANGED} extra that contains the {@link ArrayList} of provider operation codes. The following codes are + * used: + *

    + *
  • 0 - for inserts
  • + *
  • 1 - for updates
  • + *
  • 2 - for deletes
  • + *
+ */ + public final static String EXTRA_OPERATIONS = "org.dmfs.tasks.OPERATIONS"; + + + /** + * Private constructor to prevent instantiation. + */ + private TaskContract() + { + } + + + /** + * A table provided for sync adapters to use for storing private sync state data. + *

+ * Only sync adapters are allowed to access this table and they may access their own rows only. + *

+ * Note that only one row per account will be stored. Updating or inserting a sync state for a specific account will override any previous sync state for + * this account. + */ + public static class SyncState implements SyncStateContract.Columns, BaseColumns + { + public final static String CONTENT_URI_PATH = "syncstate"; + + + /** + * Get the sync state content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + /** + * Get the base content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(); + } + + + /** + * A set of columns for synchronization purposes. These columns exist in {@link Tasks} and in {@link TaskLists} but have different meanings. Only sync + * adapters are allowed to change these values. + * + * @author Marten Gajda + */ + public interface CommonSyncColumns + { + + /** + * A unique Sync ID as set by the sync adapter. + *

+ * Value: String + *

+ */ + String _SYNC_ID = "_sync_id"; + + /** + * Sync version as set by the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC_VERSION = "sync_version"; + + /** + * Indicates that a task or a task list has been changed. + *

+ * Value: Integer + *

+ */ + String _DIRTY = "_dirty"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC1 = "sync1"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC2 = "sync2"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC3 = "sync3"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC4 = "sync4"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC5 = "sync5"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC6 = "sync6"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC7 = "sync7"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC8 = "sync8"; + + } + + + /** + * Additional sync columns for task lists. + * + * @author Marten Gajda + */ + public interface TaskListSyncColumns + { + + /** + * The name of the account this list belongs to. This field is write-once. + *

+ * Value: String + *

+ */ + String ACCOUNT_NAME = "account_name"; + + /** + * The type of the account this list belongs to. This field is write-once. + *

+ * Value: String + *

+ */ + String ACCOUNT_TYPE = "account_type"; + } + + + /** + * Additional sync columns for tasks. + * + * @author Marten Gajda + */ + public interface TaskSyncColumns + { + /** + * The UID of a task. This is field can be changed by a sync adapter only. + *

+ * Value: String + *

+ */ + String _UID = "_uid"; + + /** + * Deleted flag of a task. This is set to 1 by the content provider when a task app deletes a task. The sync adapter has to remove the task + * again to finish the removal. This value is read-only. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String _DELETED = "_deleted"; + } + + + /** + * Data columns of task lists. + * + * @author Marten Gajda + */ + public interface TaskListColumns + { + + /** + * List ID. + *

+ * Value: Long + *

+ *

+ * read-only + *

+ */ + String _ID = "_id"; + + /** + * The name of the task list. + *

+ * Value: String + *

+ */ + String LIST_NAME = "list_name"; + + /** + * The color of this list as integer (0xaarrggbb). Only the sync adapter can change this. + *

+ * Value: Integer + *

+ */ + String LIST_COLOR = "list_color"; + + /** + * The access level a user has on this list. This value is not used yet, sync adapters should set it to 0. + *

+ * Value: Integer + *

+ */ + String ACCESS_LEVEL = "list_access_level"; + + /** + * Indicates that a task list is set to be visible. + *

+ * Value: Integer (0 or 1) + *

+ */ + String VISIBLE = "visible"; + + /** + * Indicates that a task list is set to be synced. + *

+ * Value: Integer (0 or 1) + *

+ */ + String SYNC_ENABLED = "sync_enabled"; + + /** + * The email address of the list owner. + *

+ * Value: String + *

+ */ + String OWNER = "list_owner"; + + } + + + /** + * The task list table holds one entry for each task list. + * + * @author Marten Gajda + */ + public static final class TaskLists implements TaskListColumns, TaskListSyncColumns, CommonSyncColumns + { + public static final String CONTENT_URI_PATH = "tasklists"; + + /** + * The default sort order. + */ + public static final String DEFAULT_SORT_ORDER = ACCOUNT_NAME + ", " + LIST_NAME; + + /** + * An array of columns only a sync adapter is allowed to change. + */ + public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { + ACCESS_LEVEL, _DIRTY, OWNER, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, + _SYNC_ID, SYNC_VERSION, }; + + + /** + * Get the task list content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + /** + * Task data columns. Defines all the values a task can have at most once. + * + * @author Marten Gajda + */ + public interface TaskColumns extends BaseColumns + { + + /** + * The row id of a task. This value is read-only + *

+ * Value: Integer + *

+ */ + String _ID = "_id"; + + /** + * The local version number of this task. The only guarantee about the value is, it's incremented whenever the task changes (this includes any + * changes applied by sync adapters). + *

+ * Note, there is no guarantee about how much it's incremented other than by at least 1. + *

+ * Value: Integer + *

+ * read-only + */ + String VERSION = "version"; + + /** + * The id of the list this task belongs to. This value is write-once and must not be null. + *

+ * Value: Integer + *

+ */ + String LIST_ID = "list_id"; + + /** + * The title of the task. + *

+ * Value: String + *

+ */ + String TITLE = "title"; + + /** + * The location of the task. + *

+ * Value: String + *

+ */ + String LOCATION = "location"; + + /** + * A geographic location related to the task. The should be a string in the format "longitude,latitude". + *

+ * Value: String + *

+ */ + String GEO = "geo"; + + /** + * The description of a task. + *

+ * Value: String + *

+ */ + String DESCRIPTION = "description"; + + /** + * The URL iCalendar field for this task. Must be a valid URI if not null- + *

+ * Value: String + *

+ */ + String URL = "url"; + + /** + * The email address of the organizer if any, {@code null} otherwise. + *

+ * Value: String + *

+ */ + String ORGANIZER = "organizer"; + + /** + * The priority of a task. This is an Integer between zero and 9. Zero means there is no priority set. 1 is the highest priority and 9 the lowest. + *

+ * Value: Integer + *

+ */ + String PRIORITY = "priority"; + + /** + * The default value of {@link #PRIORITY}. + */ + int PRIORITY_DEFAULT = 0; + + /** + * The classification of a task. This value must be either null or one of {@link #CLASSIFICATION_PUBLIC}, {@link #CLASSIFICATION_PRIVATE}, + * {@link #CLASSIFICATION_CONFIDENTIAL}. + *

+ * Value: Integer + *

+ */ + String CLASSIFICATION = "class"; + + /** + * Classification value for public tasks. + */ + int CLASSIFICATION_PUBLIC = 0; + + /** + * Classification value for private tasks. + */ + int CLASSIFICATION_PRIVATE = 1; + + /** + * Classification value for confidential tasks. + */ + int CLASSIFICATION_CONFIDENTIAL = 2; + + /** + * Default value of {@link #CLASSIFICATION}. + */ + Integer CLASSIFICATION_DEFAULT = null; + + /** + * Date of completion of this task in milliseconds since the epoch or {@code null} if this task has not been completed yet. + *

+ * Value: Long + *

+ */ + String COMPLETED = "completed"; + + /** + * Indicates that the date of completion is an all-day date. + *

+ * Value: Integer + *

+ */ + String COMPLETED_IS_ALLDAY = "completed_is_allday"; + + /** + * A number between 0 and 100 that indicates the progress of the task or null. + *

+ * Value: Integer (0-100) + *

+ */ + String PERCENT_COMPLETE = "percent_complete"; + + /** + * The status of this task. One of {@link #STATUS_NEEDS_ACTION},{@link #STATUS_IN_PROCESS}, {@link #STATUS_COMPLETED}, {@link #STATUS_CANCELLED}. + *

+ * Value: Integer + *

+ */ + String STATUS = "status"; + + /** + * A specific status indicating that nothing has been done yet. + */ + int STATUS_NEEDS_ACTION = 0; + + /** + * A specific status indicating that some work has been done. + */ + int STATUS_IN_PROCESS = 1; + + /** + * A specific status indicating that the task is completed. + */ + int STATUS_COMPLETED = 2; + + /** + * A specific status indicating that the task has been cancelled. + */ + int STATUS_CANCELLED = 3; + + /** + * The default status is "needs action". + */ + int STATUS_DEFAULT = STATUS_NEEDS_ACTION; + + /** + * A flag that indicates a task is new (i.e. not work has been done yet). This flag is read-only. Its value is 1 when + * {@link #STATUS} equals {@link #STATUS_NEEDS_ACTION} and 0 otherwise. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String IS_NEW = "is_new"; + + /** + * A flag that indicates a task is closed (no more work has to be done). This flag is read-only. Its value is 1 when + * {@link #STATUS} equals {@link #STATUS_COMPLETED} or {@link #STATUS_CANCELLED} and 0 otherwise. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String IS_CLOSED = "is_closed"; + + /** + * An individual color for this task in the format 0xaarrggbb or {@code null} to use {@link TaskListColumns#LIST_COLOR} instead. + *

+ * Value: Integer + *

+ */ + String TASK_COLOR = "task_color"; + + /** + * When this task starts in milliseconds since the epoch. + *

+ * Value: Long + *

+ */ + String DTSTART = "dtstart"; + + /** + * Boolean: flag that indicates that this is an all-day task. + */ + String IS_ALLDAY = "is_allday"; + + /** + * When this task has been created in milliseconds since the epoch. + *

+ * Value: Long + *

+ */ + String CREATED = "created"; + + /** + * When this task had been modified the last time in milliseconds since the epoch. + *

+ * Value: Long + *

+ */ + String LAST_MODIFIED = "last_modified"; + + /** + * String: An Olson Id of the time zone of this task. If this value is null, it's automatically replaced by the local time zone. + */ + String TZ = "tz"; + + /** + * When this task is due in milliseconds since the epoch. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task + * has no due date). + *

+ * Value: Long + *

+ */ + String DUE = "due"; + + /** + * The duration of this task. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task has no due date). Setting a + * {@link #DURATION} is not allowed when {@link #DTSTART} is null. The Value must be a duration string as in RFC 5545 Section 3.3.6. + *

+ * Value: String + *

+ */ + String DURATION = "duration"; + + /** + * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 + * and RFC 5545 Section 3.3.5) that contains dates of instances of e recurring task. + * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. + *

+ * This value must be {@code null} for exception instances. + *

+ * Value: String + *

+ */ + String RDATE = "rdate"; + + /** + * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 + * and RFC 5545 Section 3.3.5) that contains dates of exceptions of a recurring task. + * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. + *

+ * This value must be {@code null} for exception instances. + *

+ * Value: String + *

+ */ + String EXDATE = "exdate"; + + /** + * A recurrence rule as specified in RFC 5545 Section 3.3.10. + *

+ * This value must be {@code null} for exception instances. + *

+ * Value: String + *

+ */ + String RRULE = "rrule"; + + /** + * The _sync_id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or + * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. + *

+ * Value: String + *

+ */ + String ORIGINAL_INSTANCE_SYNC_ID = "original_instance_sync_id"; + + /** + * The row id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or + * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. + *

+ * Value: Long + *

+ */ + String ORIGINAL_INSTANCE_ID = "original_instance_id"; + + /** + * The time in milliseconds since the Epoch of the original instance that is overridden by this instance or null if this task is not a + * recurring instance. + *

+ * Value: Long + *

+ */ + String ORIGINAL_INSTANCE_TIME = "original_instance_time"; + + /** + * A flag indicating that the original instance was an all-day task. + *

+ * Value: Integer + *

+ */ + String ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"; + + /** + * The row id of the parent task. null if the task has no parent task. + *

+ * Note, when writing this value the task {@link Property.Relation} properties are updated accordingly. Any parent or child relations which + * make this a child of another task are deleted and a new {@link Property.Relation#RELTYPE_PARENT} relation pointing to the new parent is created. + * Be aware that Siblings will be split, i.e. they are not moved to the new parent. Currently this might cause siblings to become orphans if they + * don't have a parent-child relationship. This behavior may change in future version. + *

+ * + *

+ * Value: Long + *

+ */ + String PARENT_ID = "parent_id"; + + /** + * The sorting of this task under it's parent task. + *

+ * Value: String + *

+ */ + String SORTING = "sorting"; + + /** + * Indicates how many alarms a task has. 0 means the task has no alarms. This field is read only as it's set automatically. + *

+ * Value: Integer + *

+ * Read-only + */ + String HAS_ALARMS = "has_alarms"; + + /** + * Indicates that this task has extended properties like attachments, alarms or relations. This field is read only as it's set automatically. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String HAS_PROPERTIES = "has_properties"; + + /** + * Indicates that this task has been pinned to the notification area. This flag is moved to the exception when an exception for the first instance of a + * recurring task is created. That means, if you edit a pinned recurring task, the pinned flag is moved to the exception and cleared from the master + * task. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String PINNED = "pinned"; + } + + + /** + * Columns that are valid in a search query. + * + * @author Marten Gajda + */ + public interface TaskSearchColumns + { + /** + * The score of a task in a search result. It's an indicator for the relevance of the task. Value is in (0, 1.0] where 0 would be "no relevance" at all + * (though the result doesn't contain such tasks). + *

+ * Value: Float + *

+ */ + String SCORE = "score"; + } + + + /** + * The task table stores the data of all tasks. + * + * @author Marten Gajda + */ + public static final class Tasks implements TaskColumns, CommonSyncColumns, TaskSyncColumns, TaskSearchColumns + { + /** + * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; + + /** + * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; + + /** + * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_NAME = TaskLists.LIST_NAME; + /** + * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. To change the color of an individual task use {@code TASK_COLOR} instead. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_COLOR = TaskLists.LIST_COLOR; + + /** + * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_OWNER = TaskLists.OWNER; + + /** + * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; + + /** + * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String VISIBLE = "visible"; + + public static final String CONTENT_URI_PATH = "tasks"; + + public static final String SEARCH_URI_PATH = "tasks_search"; + + public static final String SEARCH_QUERY_PARAMETER = "q"; + + public static final String DEFAULT_SORT_ORDER = DUE; + + public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { + _DIRTY, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, _SYNC_ID, + SYNC_VERSION, }; + + + /** + * Get the tasks content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + + public static Uri getSearchUri(String authority, String query) + { + Uri.Builder builder = getUriFactory(authority).getUri(SEARCH_URI_PATH).buildUpon(); + builder.appendQueryParameter(SEARCH_QUERY_PARAMETER, Uri.encode(query)); + return builder.build(); + } + } + + + /** + * Columns of a task instance. + * + * @author Yannic Ahrens + * @author Marten Gajda + */ + public interface InstanceColumns + { + /** + * _ID of task this instance belongs to. + *

+ * Value: Long + *

+ */ + String TASK_ID = "task_id"; + + /** + * The start date of an instance in milliseconds since the epoch or null if the instance has no start date. At present this is read only. + *

+ * Value: Long + *

+ */ + String INSTANCE_START = "instance_start"; + + /** + * The due date of an instance in milliseconds since the epoch or null if the instance has no due date. At present this is read only. + *

+ * Value: Long + *

+ */ + String INSTANCE_DUE = "instance_due"; + + /** + * This column should be used in an order clause to sort instances by start date. The only guarantee about the values in this column is the sort order. + * Don't make any other assumptions about the value. + *

+ * Value: Long + *

+ *

+ * read-only + *

+ */ + String INSTANCE_START_SORTING = "instance_start_sorting"; + + /** + * This column should be used in an order clause to sort instances by due date. The only guarantee about the values in this column is the sort order. + * Don't make any other assumptions about the value. + *

+ * Value: Long + *

+ *

+ * read-only + *

+ */ + String INSTANCE_DUE_SORTING = "instance_due_sorting"; + + /** + * The duration of an instance in milliseconds or null if the instance has only one of start or due date or none of both. At present this + * is read only. + *

+ * Value: Long + *

+ */ + String INSTANCE_DURATION = "instance_duration"; + + /** + * The start of the original instance as specified in the master task. For non-recurring task instances this is {@code null}. + *

+ * For recurring tasks, these are the timestamps which have been derived from the recurrence rule or dates, except those specified as exdates. + */ + String INSTANCE_ORIGINAL_TIME = "instance_original_time"; + + /** + * The distance of the instance from the current one. For closed instances this is always {@code -1}, for the current instance this is {@code 0}. For + * the instance after the current one this is {@code 1}, for the instance after that one it's {@code 2}, etc.. + *

+ * Value: Integer + *

+ * read-only + */ + String DISTANCE_FROM_CURRENT = "distance_from_current"; + } + + + /** + * A table containing one entry per task instance. This table is writable in order to allow modification of single instances of a task. Write operations to + * this table will be converted into operations on overrides and forwarded to the task table. + *

+ * Note: The {@link #DTSTART}, {@link #DUE} values of instances of recurring tasks represent the actual instance values, i.e. they are different for each + * instance ({@link #DURATION} is always {@code null}). + *

+ * Also, none of the instances are recurring themselves, so {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} are always {@code null}. + *

+ * TODO: Insert all instances of recurring tasks. + *

+ * The following operations are supported: + *

+ *

Insert

+ *

+ * Note, the data of an insert must not contain the fields {@link #RRULE}, {@link #RDATE} or {@link #EXDATE}. If the new instance belongs to an existing + * task the data must contain the fields {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME}. Also note, this table supports writing {@link + * #DURATION} (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} + * {@link #DUE} date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. + *

+ * If there already is an instance (with or without override) for the given {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME} an exception + * is thrown. + *

+ *
ORIGINAL_INSTANCE_ID valueResult
absent or emptyA new non-recurring task is created with the given + * values.
a valid {@link Tasks} row {@code _ID}An {@link #RDATE} for the given {@link #ORIGINAL_INSTANCE_TIME} time is added to + * the given master task, any {@link #EXDATE} for this time is removed. The task is inserted as an override to the given master. No fields are inherited + * though. {@link #ORIGINAL_INSTANCE_ALLDAY} will be set to {@link #IS_ALLDAY} of the master. + *

+ * Note, if the given master is non-recurring, this operation will turn it into a recurring task.

invalid {@link Tasks} row {@code + * _ID}An exception is thrown.
+ *

+ *

Update

+ *

+ * Note, the data of an update must not contain any fields related to recurrence ({@link #RRULE}, {@link #RDATE}, {@link #EXDATE}, {@link + * #ORIGINAL_INSTANCE_ID}, {@link #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY}). Also note, this table supports writing {@link #DURATION} + * (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} {@link #DUE} + * date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. + *

+ * + *
Target task typeResult
Recurring master taskA new override is created with the given data.

Note, + * any fields which are not provided are inherited from the master, except for {@link #DTSTART} and {@link #DUE} which will be inherited from the instance + * and {@link #DURATION}, {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} which are set to {@code null}. {@link #ORIGINAL_INSTANCE_ID}, {@link + * #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY} will be set accordingly.

Single instance taskThe task is + * updated with the given values.
Recurrence override with existing masterThe task is updated with the given values.
Recurrence override without existing masterThe task is updated with the given values.
+ *

+ *

Delete

+ *

+ * + * + *
Target task typeResult
Recurring master taskAn {@link #EXDATE} for this instance is added, any {@link + * #RDATE} for this instance is removed. The instance row is removed.

TODO: mark the task deleted if the remaining recurrence set is empty

Single instance taskThe {@link Tasks#_DELETED} flag of the task is set.
Recurrence override with existing + * masterThe {@link Tasks#_DELETED} flag of the override is set, an {@link #EXDATE} for this instance is added to the master, any {@link #RDATE} + * for this instance is removed from the master. TODO: mark the master deleted if the remaining recurrence set of the master is empty
Recurrence override without existing masterThe {@link Tasks#_DELETED} flag of the task is set.
+ * + * @author Yannic Ahrens + * @author Marten Gajda + */ + public static final class Instances implements TaskColumns, InstanceColumns + { + + /** + * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; + + /** + * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; + + /** + * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_NAME = TaskLists.LIST_NAME; + /** + * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. To change the color of an individual task use {@code TASK_COLOR} instead. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_COLOR = TaskLists.LIST_COLOR; + + /** + * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_OWNER = TaskLists.OWNER; + + /** + * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; + + /** + * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String VISIBLE = "visible"; + + /** + * Flag indicating that ths is an instance of a recurring task. + *

+ * Value: Integer + *

+ * read-only + */ + public static final String IS_RECURRING = "is_recurring"; + + public static final String CONTENT_URI_PATH = "instances"; + + public static final String DEFAULT_SORT_ORDER = INSTANCE_DUE_SORTING; + + + /** + * Get the instances content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + /** + * Available values in Categories. + *

+ * Categories are per account. It's up to the front-end to ensure consistency of category colors across accounts. + * + * @author Marten Gajda + */ + public interface CategoriesColumns + { + + String _ID = "_id"; + + String ACCOUNT_NAME = "account_name"; + + String ACCOUNT_TYPE = "account_type"; + + String NAME = "name"; + + String COLOR = "color"; + } + + + public static final class Categories implements CategoriesColumns + { + + public static final String CONTENT_URI_PATH = "categories"; + + public static final String DEFAULT_SORT_ORDER = NAME; + + + /** + * Get the categories content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + public interface AlarmsColumns + { + String ALARM_ID = "alarm_id"; + + String LAST_TRIGGER = "last_trigger"; + + String NEXT_TRIGGER = "next_trigger"; + } + + + public static final class Alarms implements AlarmsColumns + { + + public static final String CONTENT_URI_PATH = "alarms"; + + + /** + * Get the alarms content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + public interface PropertySyncColumns + { + String SYNC1 = "prop_sync1"; + + String SYNC2 = "prop_sync2"; + + String SYNC3 = "prop_sync3"; + + String SYNC4 = "prop_sync4"; + + String SYNC5 = "prop_sync5"; + + String SYNC6 = "prop_sync6"; + + String SYNC7 = "prop_sync7"; + + String SYNC8 = "prop_sync8"; + } + + + public interface PropertyColumns + { + + String PROPERTY_ID = "property_id"; + + String TASK_ID = "task_id"; + + String MIMETYPE = "mimetype"; + + String VERSION = "prop_version"; + + String DATA0 = "data0"; + + String DATA1 = "data1"; + + String DATA2 = "data2"; + + String DATA3 = "data3"; + + String DATA4 = "data4"; + + String DATA5 = "data5"; + + String DATA6 = "data6"; + + String DATA7 = "data7"; + + String DATA8 = "data8"; + + String DATA9 = "data9"; + + String DATA10 = "data10"; + + String DATA11 = "data11"; + + String DATA12 = "data12"; + + String DATA13 = "data13"; + + String DATA14 = "data14"; + + String DATA15 = "data15"; + } + + + public static final class Properties implements PropertySyncColumns, PropertyColumns + { + + public static final String CONTENT_URI_PATH = "properties"; + + public static final String DEFAULT_SORT_ORDER = DATA0; + + + /** + * Get the properties content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + public interface Property + { + /** + * Attached documents. + *

+ * Note: Attachments are write-once. To change an attachment you'll have to remove and re-add it. + *

+ * + * @author Marten Gajda + */ + interface Attachment extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attachment"; + + /** + * URL of the attachment. This is the link that points to the attached resource. + *

+ * Value: String + *

+ */ + String URL = DATA1; + + /** + * The display name of the attachment, if any. + *

+ * Value: String + *

+ */ + String DISPLAY_NAME = DATA2; + + /** + * Content-type of the attachment. + *

+ * Value: String + *

+ */ + String FORMAT = DATA3; + + /** + * File size of the attachment or -1 if unknown. + *

+ * Value: Long + *

+ */ + String SIZE = DATA4; + + /** + * A content {@link Uri} that can be used to retrieve the attachment. Sync adapters can set this field if they know how to download the attachment + * without going through the browser. + *

+ * Value: String + *

+ */ + String CONTENT_URI = DATA5; + + } + + + interface Attendee extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attendee"; + + /** + * Name of the contact, if known. + *

+ * Value: String + *

+ */ + String NAME = DATA0; + + /** + * Email address of the contact. + *

+ * Value: String + *

+ */ + String EMAIL = DATA1; + + String ROLE = DATA2; + + String STATUS = DATA3; + + String RSVP = DATA4; + } + + + /** + * Categories are immutable. For creation is either the category id or name necessary + */ + interface Category extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/category"; + + /** + * Row id of the category. + *

+ * Value: Long + *

+ */ + String CATEGORY_ID = DATA0; + + /** + * The name of the category + *

+ * Value: String + *

+ */ + String CATEGORY_NAME = DATA1; + + /** + * The decimal coded color of the category + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String CATEGORY_COLOR = DATA2; + } + + + interface Comment extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/comment"; + + /** + * Comment text. + *

+ * Value: String + *

+ */ + String COMMENT = DATA0; + + /** + * Language code of the comment as defined in RFC5646 or null. + *

+ * Value: String + *

+ */ + String LANGUAGE = DATA1; + } + + + interface Contact extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/contact"; + + String NAME = DATA0; + + String LANGUAGE = DATA1; + } + + + /** + * Relations of a task. + *

+ * When writing a relation, exactly one of {@link #RELATED_ID} or {@link #RELATED_UID} must be present. The missing value and {@link + * #RELATED_CONTENT_URI} will be populated automatically if possible. + *

+ * {@link Tasks#PARENT_ID} is updated automatically if possible. + */ + interface Relation extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/relation"; + + /** + * The row id of the related task. May be -1 if the property doesn't refer to a task in this database or if it doesn't refer to a task + * at all. + *

+ * Value: long + *

+ */ + String RELATED_ID = DATA1; + + /** + * The relation type. This must be one of the {@code RELTYPE_*} values. + *

+ * Value: int + *

+ */ + String RELATED_TYPE = DATA2; + + /** + * The UID of the related object. + *

+ * Value: String + *

+ */ + String RELATED_UID = DATA3; + + /** + * The content Uri of a related object in another Android content provider, if found. + *

+ * Value: String (URI) + *

+ *

+ * This field is read-only. + *

+ */ + String RELATED_CONTENT_URI = DATA5; + + /** + * The related object is the parent of the object owning this relation. + */ + int RELTYPE_PARENT = 0; + + /** + * The related object is the child of the object owning this relation. + */ + int RELTYPE_CHILD = 1; + + /** + * The related object is a sibling of the object owning this relation. + */ + int RELTYPE_SIBLING = 2; + + } + + + interface Alarm extends PropertyColumns + { + + int ALARM_TYPE_NOTHING = 0; + + int ALARM_TYPE_MESSAGE = 1; + + int ALARM_TYPE_EMAIL = 2; + + int ALARM_TYPE_SMS = 3; + + int ALARM_TYPE_SOUND = 4; + + int ALARM_REFERENCE_DUE_DATE = 1; + + int ALARM_REFERENCE_START_DATE = 2; + + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/alarm"; + + /** + * Number of minutes from the reference date when the alarm goes off. If the value is < 0 the alarm will go off after the reference date. + *

+ * Value: Integer + *

+ */ + String MINUTES_BEFORE = DATA0; + + /** + * The reference date for the alarm. Either {@link #ALARM_REFERENCE_DUE_DATE} or {@link #ALARM_REFERENCE_START_DATE}. + *

+ * Value: Integer + *

+ */ + String REFERENCE = DATA1; + + /** + * A message that appears with the alarm. + *

+ * Value: String + *

+ */ + String MESSAGE = DATA2; + + /** + * The type of the alarm. Use the provided alarm types {@link #ALARM_TYPE_MESSAGE}, {@link #ALARM_TYPE_SOUND}, {@link #ALARM_TYPE_NOTHING}, + * {@link #ALARM_TYPE_EMAIL} and {@link #ALARM_TYPE_SMS}. + *

+ * Value: Integer + *

+ */ + String ALARM_TYPE = DATA3; + } + + } + + + private static synchronized UriFactory getUriFactory(String authority) + { + UriFactory uriFactory = sUriFactories.get(authority); + if (uriFactory == null) + { + uriFactory = new UriFactory(authority); + uriFactory.addUri(SyncState.CONTENT_URI_PATH); + uriFactory.addUri(TaskLists.CONTENT_URI_PATH); + uriFactory.addUri(Tasks.CONTENT_URI_PATH); + uriFactory.addUri(Tasks.SEARCH_URI_PATH); + uriFactory.addUri(Instances.CONTENT_URI_PATH); + uriFactory.addUri(Categories.CONTENT_URI_PATH); + uriFactory.addUri(Alarms.CONTENT_URI_PATH); + uriFactory.addUri(Properties.CONTENT_URI_PATH); + sUriFactories.put(authority, uriFactory); + + } + return uriFactory; + } + +} diff --git a/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java b/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java new file mode 100644 index 0000000..0092aca --- /dev/null +++ b/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.contract; + +import android.net.Uri; + +import java.util.HashMap; +import java.util.Map; + + +/** + * TODO + */ +public final class UriFactory +{ + private final String mAuthority; + private final Map mUriMap = new HashMap(16); + + + UriFactory(String authority) + { + mAuthority = authority; + mUriMap.put(null, Uri.parse("content://" + authority)); + } + + + void addUri(String path) + { + mUriMap.put(path, Uri.parse("content://" + mAuthority + "/" + path)); + } + + + Uri getUri() + { + return mUriMap.get(null); + } + + + Uri getUri(String path) + { + return mUriMap.get(path); + } +} diff --git a/provider/src/main/res/drawable/ic_24_agendula_tasks.xml b/provider/src/main/res/drawable/ic_24_agendula_tasks.xml new file mode 100644 index 0000000..a5420b4 --- /dev/null +++ b/provider/src/main/res/drawable/ic_24_agendula_tasks.xml @@ -0,0 +1,4 @@ + + + diff --git a/provider/src/main/res/values-cs/strings.xml b/provider/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..9b043e6 --- /dev/null +++ b/provider/src/main/res/values-cs/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + přečíst úkoly + Číst úkoly a seznamy úkolů + zapsat úkoly + Vytvořit, změnit a smazat úkoly a seznamy úkolů + Vytvořit, změnit a smazat úkoly a seznamy úkolů + + diff --git a/provider/src/main/res/values-de/strings.xml b/provider/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..2f16cc7 --- /dev/null +++ b/provider/src/main/res/values-de/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + Aufgaben lesen + Aufgaben lesen + Aufgaben schreiben + Aufgaben und Aufgabenlisten erstellen, bearbeiten und löschen + Aufgaben lesen und verwalten + + diff --git a/provider/src/main/res/values-es/strings.xml b/provider/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..c2808fb --- /dev/null +++ b/provider/src/main/res/values-es/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + leer tareas + Leer tareas y listas de tareas + escribir tareas + Crear, modificar y borrar tareas y listas de tareas + Crear, modificar y borrar tareas y listas de tareas + + diff --git a/provider/src/main/res/values-fr/strings.xml b/provider/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..165e07d --- /dev/null +++ b/provider/src/main/res/values-fr/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + Lire tâches + Autoriser une application à lire les tâches de la listes de tâches + Écrire tâches + Autoriser une application à écrire des tâches dans la listes de tâches + Autoriser une application à écrire des tâches dans la listes de tâches + + diff --git a/provider/src/main/res/values-hu/strings.xml b/provider/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..5f73108 --- /dev/null +++ b/provider/src/main/res/values-hu/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + feladatok olvasása + Feladatok és feladatlisták olvasása + feladatok írása + Feladatok és feladatlisták létrehozása, módosítása és törlése + Feladatok és feladatlisták létrehozása, módosítása és törlése + + diff --git a/provider/src/main/res/values-it/strings.xml b/provider/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..c321b6f --- /dev/null +++ b/provider/src/main/res/values-it/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + lettura attività + lettura attività ed elenchi di attività + scrittura attività + creazione, modifica e eliminazione di attività ed elenchi di attività + accesso e modifica delle attività + + diff --git a/provider/src/main/res/values-ja/strings.xml b/provider/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..5a6f8b7 --- /dev/null +++ b/provider/src/main/res/values-ja/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + タスクを読む + タスクとタスクリストを読み込みます。 + タスクを書く + タスクとタスクリストを作成、変更、削除します。 + タスクとタスクリストを作成、変更、削除します。 + + diff --git a/provider/src/main/res/values-nl/strings.xml b/provider/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..cd41d16 --- /dev/null +++ b/provider/src/main/res/values-nl/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + leestaken + Staat een app toe om taken in je takenlijst te lezen + schrijftaken + Staat een app toe om taken in je takenlijst aan te maken + Staat een app tpe om taken in je takenlijst aan te maken + + diff --git a/provider/src/main/res/values-pl/strings.xml b/provider/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..41f57c1 --- /dev/null +++ b/provider/src/main/res/values-pl/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + Czytaj zadania + Pozwala aplikacji na czytanie zadań z Twojej listy zadań + Zapisuj zadania + Pozwala aplikacji na zapisywanie zadań na Twojej liście zadań + Pozwala aplikacji na zapisywanie zadań na Twojej liście zadań + + diff --git a/provider/src/main/res/values-pt-rBR/strings.xml b/provider/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..c93660c --- /dev/null +++ b/provider/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + ler tarefas + Permite que um aplicativo leia as tarefas na sua lista de tarefas + escrever tarefas + Permite que um aplicativo escreva tarefas na sua lista de tarefas + Permite que um aplicativo escreva tarefas na sua lista de tarefas + + \ No newline at end of file diff --git a/provider/src/main/res/values-pt-rPT/strings.xml b/provider/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..73ea9e8 --- /dev/null +++ b/provider/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + ler tarefas + Ler tarefas e listas de tarefas + escrever tarefas + Criar, modificar e eliminar tarefas e listas de tarefas + Criar, modificar e eliminar tarefas e listas de tarefas + + diff --git a/provider/src/main/res/values-ru/strings.xml b/provider/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..4b8d77f --- /dev/null +++ b/provider/src/main/res/values-ru/strings.xml @@ -0,0 +1,16 @@ + + + + + + + + + + чтение задач + Разрешить приложению читать задачи из Ваших списков задач + запись задач + Разрешить приложению сохранять задачи в Ваших списках задач + Разрешить приложению сохранять задачи в Ваших списках задач + + diff --git a/provider/src/main/res/values-sr/strings.xml b/provider/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..3562f79 --- /dev/null +++ b/provider/src/main/res/values-sr/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + читање задатака + Дозвољава апликацији да чита задатке са ваше листе задатака + упис задатака + Дозвољава апликацији да уноси задатке у вашу листу задатака + Дозвољава апликацији да уноси задатке у вашу листу задатака + + diff --git a/provider/src/main/res/values-uk/strings.xml b/provider/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..1cdb330 --- /dev/null +++ b/provider/src/main/res/values-uk/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + читання завдань + Дозволити додатку читати ваші завдання з ваших переліків завдань + запис завдань + Дозволити додатку зберігати завдання до ваших переліків завдань + Дозволити додатку зберігати завдання до ваших переліків завдань + + diff --git a/provider/src/main/res/values/agendula_defaults.xml b/provider/src/main/res/values/agendula_defaults.xml new file mode 100644 index 0000000..816a10b --- /dev/null +++ b/provider/src/main/res/values/agendula_defaults.xml @@ -0,0 +1,16 @@ + + + + + de.jeanlucmakiola.agendula.tasks + + diff --git a/provider/src/main/res/values/agendula_provider_changed_receivers.xml b/provider/src/main/res/values/agendula_provider_changed_receivers.xml new file mode 100644 index 0000000..a4ac2ab --- /dev/null +++ b/provider/src/main/res/values/agendula_provider_changed_receivers.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/provider/src/main/res/values/strings.xml b/provider/src/main/res/values/strings.xml new file mode 100644 index 0000000..a65c33b --- /dev/null +++ b/provider/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + + Agendula tasks + + + + read tasks + read tasks and task lists + write tasks + create, modify and delete tasks and task lists + access and manage tasks + + diff --git a/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java b/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java new file mode 100644 index 0000000..04c332b --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java @@ -0,0 +1,215 @@ +/* + * Copyright 2026 Jean-Luc Makiola + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.android.controller.ContentProviderController; +import org.robolectric.RuntimeEnvironment; + +import de.jeanlucmakiola.agendula.provider.R; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; + + +/** + * Agendula's own test, not upstream's. + *

+ * Local-only mode is the default Agendula ships, and it rests on a claim worth pinning down: that the provider works with no account on the device at + * all. That is open question 3 in {@code docs/STORAGE-AND-SYNC.md}, and it is not obvious — we dropped {@code GET_ACCOUNTS} from a provider whose list + * cleanup was written assuming it, so the failure this guards against is the provider quietly deleting the user's task lists. See change 1 in + * {@code provider/PROVENANCE.md}. + *

+ * Robolectric, so this is not a substitute for running it on a device — but it does hold the invariant against future edits to the cleanup path. + * + * @author Jean-Luc Makiola + */ +@RunWith(RobolectricTestRunner.class) +public class ProviderAccountCleanupTest +{ + private ContentProviderController mController; + private ContentResolver mResolver; + private String mAuthority; + + + @Before + public void setUp() + { + // Robolectric ships no aarch64 SQLite in either backend: the native runtime refuses outright and the LEGACY sqlite4java shadow throws + // "Architecture 'aarch64' is not supported". Every other test in this module is architecture-independent; this is the only one that opens a + // database. Skipping beats failing on an ARM64 dev machine — but note that means these assertions are only actually exercised on x86_64, which + // CI is. If this ever starts skipping in CI, the invariant has stopped being checked at all. + assumeFalse( + "Robolectric has no SQLite backend for aarch64 — this class only runs on x86_64", + System.getProperty("os.arch", "").contains("aarch64")); + + Context context = RuntimeEnvironment.getApplication(); + Utils.clearOwnAccountTypesCache(); + mAuthority = context.getString(R.string.agendula_tasks_authority); + mController = Robolectric.buildContentProvider(TaskProvider.class).create(mAuthority); + mResolver = context.getContentResolver(); + } + + + @After + public void tearDown() + { + Utils.clearOwnAccountTypesCache(); + // Null when setUp bailed out on the architecture assumption above. + if (mController != null) + { + mController.shutdown(); + } + } + + + /** + * The authority is ours, not dmfs's. Guards against a resource merge or a careless resync quietly handing our provider back to {@code org.dmfs.tasks}, + * which would make Agendula and OpenTasks mutually uninstallable. + */ + @Test + public void authorityIsOurs() + { + assertEquals("de.jeanlucmakiola.agendula.tasks", mAuthority); + } + + + /** + * The whole of Local mode: create a local list and a task in it with no account anywhere on the device, and read both back. + */ + @Test + public void localListAndTaskSurviveWithNoAccount() + { + Uri listUri = insertLocalList("Groceries"); + assertNotNull(listUri); + + long listId = Long.parseLong(listUri.getLastPathSegment()); + ContentValues task = new ContentValues(); + task.put(Tasks.LIST_ID, listId); + task.put(Tasks.TITLE, "Oat milk"); + Uri taskUri = mResolver.insert(Tasks.getContentUri(mAuthority), task); + assertNotNull(taskUri); + + assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); + assertEquals(1, countRows(Tasks.getContentUri(mAuthority))); + } + + + /** + * The bug this exists for. An account-update sweep reporting zero accounts — exactly what we see without {@code GET_ACCOUNTS} — must not take a synced + * list with it. Upstream would delete this list. + */ + @Test + public void cleanUpDoesNotPruneListsOfAccountTypesWeDoNotAuthenticate() + { + insertSyncedList("Shared shopping", "someone@example.org", "bitfire.at.davdroid"); + assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); + + // No authenticator in this package, so nothing is prunable and no account is visible. + Utils.cleanUpLists( + RuntimeEnvironment.getApplication(), + mController.get().getDatabaseHelper(RuntimeEnvironment.getApplication()).getWritableDatabase(), + new Account[0], + mAuthority); + + assertEquals( + "a list whose account type we cannot enumerate must never be pruned", + 1, + countRows(TaskLists.getContentUri(mAuthority))); + } + + + /** + * Local lists are exempt from cleanup regardless — upstream's rule, restated here because Local mode depends on it and it is easy to lose in a resync. + */ + @Test + public void cleanUpNeverPrunesLocalLists() + { + insertLocalList("Groceries"); + + Utils.cleanUpLists( + RuntimeEnvironment.getApplication(), + mController.get().getDatabaseHelper(RuntimeEnvironment.getApplication()).getWritableDatabase(), + new Account[0], + mAuthority); + + assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); + } + + + /** + * With no authenticator of our own, the prunable set is empty — which is what makes the two tests above hold by construction rather than by luck. + */ + @Test + public void weAuthenticateNoAccountTypesYet() + { + assertTrue(Utils.ownAccountTypes(RuntimeEnvironment.getApplication()).isEmpty()); + } + + + private Uri insertLocalList(String name) + { + return insertSyncedList(name, TaskContract.LOCAL_ACCOUNT_NAME, TaskContract.LOCAL_ACCOUNT_TYPE); + } + + + private Uri insertSyncedList(String name, String accountName, String accountType) + { + ContentValues values = new ContentValues(); + values.put(TaskLists.LIST_NAME, name); + values.put(TaskLists.VISIBLE, 1); + values.put(TaskLists.SYNC_ENABLED, 1); + return mResolver.insert(asSyncAdapter(TaskLists.getContentUri(mAuthority), accountName, accountType), values); + } + + + private static Uri asSyncAdapter(Uri uri, String accountName, String accountType) + { + return uri.buildUpon() + .appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(TaskContract.ACCOUNT_NAME, accountName) + .appendQueryParameter(TaskContract.ACCOUNT_TYPE, accountType) + .build(); + } + + + private int countRows(Uri uri) + { + try (Cursor cursor = mResolver.query(uri, null, null, null, null)) + { + assertNotNull(cursor); + return cursor.getCount(); + } + } +} diff --git a/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java b/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java new file mode 100644 index 0000000..29ff809 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java @@ -0,0 +1,223 @@ +/* + * Copyright 2018 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; + +import org.dmfs.iterables.EmptyIterable; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.rfc5545.DateTime; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DateTimeIterableFieldAdapterTest +{ + @Test + public void testFieldName() + { + assertThat(new DateTimeIterableFieldAdapter<>("x", "y").fieldName(), is("x")); + } + + + @Test + public void testGetFromCVAllDay1() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109"))); + } + + + @Test + public void testGetFromCVAllDay2() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109,20180110"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109"), DateTime.parse("20180110"))); + } + + + @Test + public void testGetFromCVFloating1() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000"); + values.putNull("y"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109T140000"))); + } + + + @Test + public void testGetFromCVFloating2() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000,20180110T140000"); + values.putNull("y"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109T140000"), DateTime.parse("20180110T140000"))); + } + + + @Test + public void testGetFromCVAbsolute1() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000Z"); + values.put("y", "Europe/Berlin"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("Europe/Berlin", "20180109T150000"))); + } + + + @Test + public void testGetFromCVAbsolute2() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000Z,20180110T140000Z"); + values.put("y", "Europe/Berlin"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"))); + } + + + @Test + public void testSetInNull() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, null); + assertThat(values.getAsString("x"), nullValue()); + } + + + @Test + public void testSetInEmpty() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, EmptyIterable.instance()); + assertThat(values.getAsString("x"), nullValue()); + } + + + @Test + public void testSetInSingleAllDay() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109"))); + assertThat(values.getAsString("x"), is("20180109")); + } + + + @Test + public void testSetInSingleFloating() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"))); + assertThat(values.getAsString("x"), is("20180109T150000")); + } + + + @Test + public void testSetInSingleAbsolute() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"))); + assertThat(values.getAsString("x"), is("20180109T140000Z")); + } + + + @Test + public void testSetInDoubleAllDay() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109"), DateTime.parse("20180110"))); + assertThat(values.getAsString("x"), is("20180109,20180110")); + } + + + @Test + public void testSetInDoubleFloating() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"), DateTime.parse("20180110T150000"))); + assertThat(values.getAsString("x"), is("20180109T150000,20180110T150000")); + } + + + @Test + public void testSetInDoubleAbsolute() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"))); + assertThat(values.getAsString("x"), is("20180109T140000Z,20180110T140000Z")); + } + + + @Test + public void testSetInMultiAllDay() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109"), DateTime.parse("20180110"), DateTime.parse("20180111"))); + assertThat(values.getAsString("x"), is("20180109,20180110,20180111")); + } + + + @Test + public void testSetInMultiFloating() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"), DateTime.parse("20180110T150000"), DateTime.parse("20180111T150000"))); + assertThat(values.getAsString("x"), is("20180109T150000,20180110T150000,20180111T150000")); + } + + + @Test + public void testSetInMultiAbsolute() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"), + DateTime.parse("Europe/Berlin", "20180111T150000"))); + assertThat(values.getAsString("x"), is("20180109T140000Z,20180110T140000Z,20180111T140000Z")); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java new file mode 100644 index 0000000..4d60f5b --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DatedTest +{ + + @Test + public void testAbsent() + { + ContentValues instanceData = new Dated(absent(), "ts", "sorting", ContentValues::new).value(); + // this shouldn't really add any values and go by the "defaults" + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testPresent() + { + DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); + + ContentValues instanceData = new Dated(new Present<>(start), "ts", "sorting", ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong("ts", start.getTimestamp())); + assertThat(instanceData, new ContentValuesWithLong("sorting", start.getInstance())); + assertThat(instanceData.size(), is(2)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java new file mode 100644 index 0000000..c8be962 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DistantTest +{ + + @Test + public void test() + { + ContentValues instanceData = new Distant(100, ContentValues::new).value(); + assertThat(instanceData.get(TaskContract.Instances.DISTANCE_FROM_CURRENT), is(100)); + assertThat(instanceData.size(), is(1)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java new file mode 100644 index 0000000..25840e9 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.TimeZone; + +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DueDatedTest +{ + + @Test + public void testNone() + { + ContentValues instanceData = new DueDated(absent(), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, nullValue(Long.class))); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, nullValue(Long.class))); + // this doesn't actually add anything, the ContentValues are expected to contain null values. + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testStartEurope() + { + DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); + + ContentValues instanceData = new DueDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } + + + @Test + public void testStartAmerica() + { + DateTime start = DateTime.parse("America/New_York", "20171208T125500"); + + ContentValues instanceData = new DueDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java new file mode 100644 index 0000000..4fbd8f5 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class EnduringTest +{ + @Test + public void testNoValue() + { + assertThat(new Enduring(ContentValues::new), hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); + assertThat(new Enduring(ContentValues::new).value().size(), is(1)); + } + + + @Test + public void testStartValue() + { + ContentValues values = new ContentValues(1); + values.put(TaskContract.Instances.INSTANCE_START, 10); + assertThat(new Enduring(() -> new ContentValues(values)), + hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); + assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(2)); + } + + + @Test + public void testDueValue() + { + ContentValues values = new ContentValues(1); + values.put(TaskContract.Instances.INSTANCE_DUE, 10); + assertThat(new Enduring(() -> new ContentValues(values)), + hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); + assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(2)); + } + + + @Test + public void testStartDueValue() + { + ContentValues values = new ContentValues(2); + values.put(TaskContract.Instances.INSTANCE_START, 1); + values.put(TaskContract.Instances.INSTANCE_DUE, 10); + assertThat(new Enduring(() -> new ContentValues(values)), hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, 9))); + assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(3)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java new file mode 100644 index 0000000..e56d8a4 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.optional.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class OverriddenTest +{ + @Test + public void testAbsent() + { + ContentValues instanceData = new Overridden(absent(), ContentValues::new).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testAbsentWithStart() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_START, 10); + + ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(1)); + } + + + @Test + public void testAbsentWithDue() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_DUE, 20); + + ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(1)); + } + + + @Test + public void testAbsentWithStartAndDue() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_START, 10); + values.put(TaskContract.Instances.INSTANCE_DUE, 20); + + ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(2)); + } + + + @Test + public void testPresent() + { + + ContentValues instanceData = new Overridden(new Present<>(new DateTime(40)), ContentValues::new).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, 40)); + assertThat(instanceData.size(), is(1)); + } + + + @Test + public void testPresentWithStartAndDue() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_START, 10); + values.put(TaskContract.Instances.INSTANCE_DUE, 20); + + ContentValues instanceData = new Overridden(new Present<>(new DateTime(40)), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, 40)); + assertThat(instanceData.size(), is(3)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java new file mode 100644 index 0000000..85676d1 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.TimeZone; + +import static org.dmfs.optional.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class StartDatedTest +{ + + @Test + public void testNone() + { + ContentValues instanceData = new StartDated(absent(), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, nullValue(Long.class))); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, nullValue(Long.class))); + // this doesn't actually add anything, the ContentValues are expected to contain null values. + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testStartEurope() + { + DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); + + ContentValues instanceData = new StartDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } + + + @Test + public void testStartAmerica() + { + DateTime start = DateTime.parse("America/New_York", "20171208T125500"); + + ContentValues instanceData = new StartDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java new file mode 100644 index 0000000..760ca34 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class TaskRelatedTest +{ + @Test + public void testValue() + { + assertThat(new TaskRelated(123, ContentValues::new), hasValue(new ContentValuesWithLong(TaskContract.Instances.TASK_ID, 123))); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java new file mode 100644 index 0000000..f78f8bf --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class VanillaInstanceDataTest +{ + @Test + public void testValue() + { + ContentValues values = new VanillaInstanceData().value(); + assertThat(values.get(TaskContract.Instances.INSTANCE_START), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_START_SORTING), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_DUE), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_DUE_SORTING), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_DURATION), nullValue()); + assertThat(values.get(TaskContract.Instances.DISTANCE_FROM_CURRENT), is(0)); + assertThat(values.get(TaskContract.Instances.INSTANCE_ORIGINAL_TIME), nullValue()); + assertThat(values.size(), is(7)); + } + +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java new file mode 100644 index 0000000..750f477 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; +import android.database.MatrixCursor; + +import org.dmfs.iterables.elementary.Seq; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.predicate.PredicateMatcher.satisfiedBy; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ContainsValuesTest +{ + @Test + public void test() + { + ContentValues values = new ContentValues(); + values.put("a", 123); + values.put("b", "stringValue"); + values.put("c", new byte[] { 3, 2, 1 }); + values.putNull("d"); + + MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b", "a", "d", "f" }); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", "123", null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2 }, "stringValue", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValueX", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 1234, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, "123", "xyz")); + cursor.addRow(new Seq<>(321, "stringValueX", "1234", "123", "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1, 0 }, "stringValueX", 1234, "123", "xyz")); + + cursor.moveToFirst(); + assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + } + + + @Test + public void testMissingColumns() + { + ContentValues values = new ContentValues(); + values.put("a", 123); + values.put("b", "stringValue"); + values.put("c", new byte[] { 3, 2, 1 }); + values.putNull("d"); + + MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b" }); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue")); + + cursor.moveToFirst(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java new file mode 100644 index 0000000..6dafc00 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.hamcrest.FeatureMatcher; +import org.hamcrest.Matcher; + +import static org.hamcrest.Matchers.is; + + +/** + * A {@link Matcher} to test if {@link ContentValues} contain a specific Long value. + *

+ * TODO: can we convert that into a more generic {@link ContentValues} matcher? It might be useful in other places. + *

+ * TODO: also consider moving this to "Test-Bolts" + */ +public final class ContentValuesWithLong extends FeatureMatcher +{ + private final String mKey; + + + public ContentValuesWithLong(String valueKey, long value) + { + this(valueKey, is(value)); + } + + + public ContentValuesWithLong(String valueKey, Matcher matcher) + { + super(matcher, "Long value " + valueKey, "Long value " + valueKey); + mKey = valueKey; + } + + + @Override + protected Long featureValueOf(ContentValues actual) + { + return actual.getAsLong(mKey); + } +} diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java new file mode 100644 index 0000000..81eff9c --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class TaskInstanceIterableTest +{ + @Test + public void testAbsolute() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } + + + @Test + public void testAllDay() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("20170606")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("20170606"), + DateTime.parse("20170608"), + DateTime.parse("20170610"), + DateTime.parse("20170612"), + DateTime.parse("20170614"), + DateTime.parse("20170616"), + DateTime.parse("20170618"), + DateTime.parse("20170620"), + DateTime.parse("20170622"), + DateTime.parse("20170624") + )); + } + + + @Test + public void testFloating() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("20170606T121314"), + DateTime.parse("20170608T121314"), + DateTime.parse("20170610T121314"), + DateTime.parse("20170612T121314"), + DateTime.parse("20170614T121314"), + DateTime.parse("20170616T121314"), + DateTime.parse("20170618T121314"), + DateTime.parse("20170620T121314"), + DateTime.parse("20170622T121314"), + DateTime.parse("20170624T121314") + )); + } + + + @Test + public void testRDate() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RDATE, new Seq<>( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } + + + @Test + public void testRDateAndRRule() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + taskAdapter.set(TaskAdapter.RDATE, new Seq<>( + DateTime.parse("Europe/Berlin", "20170606T121313"), + DateTime.parse("Europe/Berlin", "20170608T121313"), + DateTime.parse("Europe/Berlin", "20170610T121313"), + DateTime.parse("Europe/Berlin", "20170612T121313"), + DateTime.parse("Europe/Berlin", "20170614T121313"), + DateTime.parse("Europe/Berlin", "20170616T121313"), + DateTime.parse("Europe/Berlin", "20170618T121313"), + DateTime.parse("Europe/Berlin", "20170620T121313"), + DateTime.parse("Europe/Berlin", "20170622T121313"), + DateTime.parse("Europe/Berlin", "20170624T121313") + )); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121313"), + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121313"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121313"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121313"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121313"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121313"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121313"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121313"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121313"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121313"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java new file mode 100644 index 0000000..552f43d --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2021 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.junit.Test; + +import java.util.TimeZone; + +import static org.dmfs.jems.hamcrest.matchers.iterator.IteratorMatcher.iteratorOf; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +public class TaskInstanceIteratorTest +{ + private final static String TIMEZONE = "Europe/Berlin"; + + + @Test + public void testAbsolute() throws InvalidRecurrenceRuleException + { + RecurrenceSet recurrenceSet = new RecurrenceSet(); + recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); + DateTime start = DateTime.parse(TIMEZONE, "20210201T120000"); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet.iterator(TimeZone.getTimeZone(TIMEZONE), start.getTimestamp()), TIMEZONE), + iteratorOf( + DateTime.parse(TIMEZONE, "20210201T120000"), + DateTime.parse(TIMEZONE, "20210202T120000"), + DateTime.parse(TIMEZONE, "20210203T120000") + ) + ); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet), + iteratorOf( + DateTime.parse(TIMEZONE, "20210201T120000"), + DateTime.parse(TIMEZONE, "20210202T120000"), + DateTime.parse(TIMEZONE, "20210203T120000") + ) + ); + } + + + @Test + public void testFloating() throws InvalidRecurrenceRuleException + { + + RecurrenceSet recurrenceSet = new RecurrenceSet(); + recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); + DateTime start = DateTime.parse("20210201T120000"); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet.iterator(null, start.getTimestamp()), null), + iteratorOf( + DateTime.parse("20210201T120000"), + DateTime.parse("20210202T120000"), + DateTime.parse("20210203T120000") + ) + ); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet), + iteratorOf( + DateTime.parse("20210201T120000"), + DateTime.parse("20210202T120000"), + DateTime.parse("20210203T120000") + ) + ); + } + + + @Test + public void testAllDay() throws InvalidRecurrenceRuleException + { + + RecurrenceSet recurrenceSet = new RecurrenceSet(); + recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); + DateTime start = DateTime.parse("20210201"); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet.iterator(null, start.getTimestamp()), null), + iteratorOf( + DateTime.parse("20210201"), + DateTime.parse("20210202"), + DateTime.parse("20210203") + ) + ); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet), + iteratorOf( + DateTime.parse("20210201"), + DateTime.parse("20210202"), + DateTime.parse("20210203") + ) + ); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java new file mode 100644 index 0000000..843b94a --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.single.elementary.ValueSingle; +import org.junit.Test; + +import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; +import static org.dmfs.jems.mockito.doubles.TestDoubles.dummy; +import static org.dmfs.jems.mockito.doubles.TestDoubles.failingMock; +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.doReturn; + + +/** + * @author Marten Gajda + */ +public class ZippedTest +{ + @Test + public void testPresent() + { + Object dummyPresentValue = new Object(); + Object dummySingleValue = new Object(); + Object dummyResult = new Object(); + BiFunction mockFunction = failingMock(BiFunction.class); + doReturn(dummyResult).when(mockFunction).value(dummyPresentValue, dummySingleValue); + assertThat(new Zipped<>(new Present<>(dummyPresentValue), new ValueSingle<>(dummySingleValue), mockFunction), hasValue(sameInstance(dummyResult))); + } + + + @Test + public void testAbsent() + { + Object dummyObject = new Object(); + // AGENDULA CHANGE: the diamond was `new Zipped<>(...)`. `absent()` pins nothing, so with no + // target type javac infers Zipped for the argument and Matcher> for the + // matcher, and the two don't meet. Java 8 let it through; we compile at 17. Naming the type + // is the whole fix — the assertion is upstream's. + assertThat(new Zipped(absent(), new ValueSingle<>(dummyObject), dummy(BiFunction.class)), hasValue(sameInstance(dummyObject))); + } +} \ No newline at end of file diff --git a/provider/src/test/resources/robolectric.properties b/provider/src/test/resources/robolectric.properties new file mode 100644 index 0000000..d5e7bba --- /dev/null +++ b/provider/src/test/resources/robolectric.properties @@ -0,0 +1,26 @@ +# Robolectric normally takes its SDK level from the module's targetSdk, but a +# library module has none to read — only :app sets one — so without this every +# @RunWith(RobolectricTestRunner.class) class fails at DefaultSdkPicker before a +# single assertion runs. +# +# 34 rather than :app's targetSdk 36: these tests exercise the provider's pure +# data plumbing (ContentValues, MatrixCursor, instance-data processors), none of +# which changed across those levels, and every level listed here costs a separate +# android-all jar download on a cold test run. +sdk=34 + +# Robolectric installs the Conscrypt security provider during environment setup +# whether a test needs TLS or not, and conscrypt-openjdk-uber ships no +# linux-aarch_64 native, so on an ARM64 machine every Robolectric test dies in +# setUpApplicationState with UnsatisfiedLinkError before reaching an assertion. +# Nothing here opens a socket — these are ContentValues and cursor tests — so the +# provider is pure overhead. Turning it off also keeps the suite arch-portable +# rather than passing on x86_64 CI and failing on an ARM laptop. +conscryptMode=OFF + +# Note for anyone running the suite on ARM64: Robolectric has no aarch64 SQLite +# in *either* backend — the native runtime refuses outright and the LEGACY +# sqlite4java shadow throws "Architecture 'aarch64' is not supported". So the one +# test class that needs a database, ProviderAccountCleanupTest, skips itself +# there rather than failing; see the assumption at the top of that class. Every +# other test in this module is architecture-independent and runs everywhere. diff --git a/settings.gradle.kts b/settings.gradle.kts index f522deb..3f9d68d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,9 @@ dependencyResolutionManagement { rootProject.name = "Agendula" include(":app") +// Agendula's own task store — the dmfs task provider vendored under our +// authority. In-tree rather than a submodule or a Maven artifact: F-Droid +// requires from-source, and the upstream AAR hardcodes dmfs's permission names +// in its manifest where they cannot be renamed. See provider/PROVENANCE.md. +include(":provider") includeBuild("floret-kit")