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")