feat(provider): ship our own task store, vendored under our own authority

Steps 2 and 3 of docs/STORAGE-AND-SYNC.md. Agendula stops depending on a tasks
provider app being installed: it now carries one.

The module

New :provider — the dmfs task provider 1.4.2 (Apache-2.0, DB 23), vendored
in-tree, renamed to authority de.jeanlucmakiola.agendula.tasks and permissions
de.jeanlucmakiola.agendula.permission.*. It coexists with OpenTasks and
tasks.org rather than replacing them; nothing collides with org.dmfs.*, so both
can be installed at once. The contract shape is untouched — same tables, same
columns — because that is what our data layer and every CalDAV engine already
speak. We own the namespace it lives in, not the schema.

Vendored rather than depended on because the permission names are hardcoded in
the upstream AAR's manifest and cannot be renamed in a prebuilt artifact; in-tree
also satisfies F-Droid's from-source rule. provider/PROVENANCE.md records the
upstream commit and every deviation, each marked with an AGENDULA CHANGE comment
at the site so the list and the code cannot drift apart.

The change that matters most is the account cleanup. Upstream holds GET_ACCOUNTS
and deletes any task list whose account it cannot see. We dropped that permission
— we only ever need our own accounts, which are visible without it — but an
account we cannot see is indistinguishable from one that was removed, so left
alone the provider would quietly delete synced lists. Cleanup is now restricted
to account types this package authenticates itself, which is currently none.
ProviderAccountCleanupTest pins that, and answers open question 3: the local path
works with no account present at all.

Also required by targetSdk 36, none of which upstream faced at 29:
FLAG_IMMUTABLE on the notification PendingIntent, an inexact-alarm fallback so a
revoked SCHEDULE_EXACT_ALARM cannot kill the app on a timezone change, and an
explicit android:exported on the receiver.

Storage modes

ProviderResolver gains a StorageMode: LOCAL (our provider) or EXTERNAL (an
installed one). Not a third SYNCED value — synced is LOCAL with an account
attached, which is derived state, and modelling it as a separate store would
imply switching sync on is a migration. It isn't.

When the user has not chosen, the tell is whether we already hold an external
provider's runtime permission. That permission is dangerous-level, so it can only
be there because an earlier version asked and they agreed — the signature of an
existing Posture A user, who must not be dropped onto an empty store. Fresh
installs get local-first.

hasPermission now short-circuits for our own provider: same-uid access bypasses
the check outright, so ProviderStatus.NEEDS_PERMISSION can no longer fire in
Local mode. That was the work item the storage-and-sync doc called for. The
resolver's platform calls moved behind ProviderEnvironment so the decision — the
part that loses people their data if wrong — is unit-tested on the JVM.

Verified: 51 vendored provider tests pass, app tests pass, lintDebug and
assembleDebug clean. ProviderAccountCleanupTest skips on ARM64, where Robolectric
has no SQLite backend, and runs on x86_64 CI. Not yet exercised on a device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 21:19:52 +02:00
parent 3d54a896ce
commit f978c3727c
139 changed files with 17424 additions and 35 deletions

View File

@@ -138,6 +138,11 @@ kotlin {
}
dependencies {
// Agendula's own task store — the dmfs provider vendored under our authority.
// Contributes a <provider> 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)

View File

@@ -2,9 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Tasks provider access. Both permission sets are declared; the active one
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
permission sets are declared, since the manifest is static; the active one
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
runtime by the permission flow. Both are dangerous-level. -->
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. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
@@ -74,13 +80,16 @@
</intent-filter>
</receiver>
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
Targets both known authorities; the host must be static. -->
<!-- Re-sync reminders when the provider changes — our own writes, and
external sync (DAVx5) in External mode. Every authority we might be
pointed at needs listing, because an intent-filter host must be a
literal: our bundled provider first, then the two external ones. -->
<receiver
android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="de.jeanlucmakiola.agendula.tasks" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>

View File

@@ -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
}
}

View File

@@ -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<Preferences> 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)
}

View File

@@ -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

View File

@@ -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<StorageMode?> = 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<Boolean> = 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")
}

View File

@@ -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
}

View File

@@ -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<TaskProvider> = 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<TaskProvider> = listOf(
TaskProvider(
authority = "org.dmfs.tasks",
readPermission = "org.dmfs.permission.READ_TASKS",

View File

@@ -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,
}

View File

@@ -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<Unit>()
/** 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()
}

View File

@@ -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(),
)

View File

@@ -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<String, String> = emptyMap(),
val granted: Set<String> = 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<String, String> = emptyMap(),
granted: Set<String> = 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()
}
}
}