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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user