Merge remote-tracking branch 'origin/release/v1.0.0' into feat/caldav-sync

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt
#	docs/PRIVACY.md
#	gradle/libs.versions.toml
This commit is contained in:
2026-09-23 13:52:33 +02:00
45 changed files with 1343 additions and 174 deletions
+20
View File
@@ -37,6 +37,26 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Also cheap, also always-on. Two failures in one: the script exits
# non-zero if this version's changelog is over the character limit
# F-Droid truncates at, and the porcelain check below catches a
# CHANGELOG.md edit whose generated fastlane file was never committed —
# which used to degrade silently into "the official listing shows the
# previous version's notes".
- name: Changelog fits F-Droid, and is committed
run: |
set -e
bash scripts/sync_changelog_to_fastlane.sh
DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs)
if [ -n "$DIRTY" ]; then
echo "$DIRTY"
echo "ERROR: the generated fastlane changelog is not what is committed." >&2
echo "Run scripts/sync_changelog_to_fastlane.sh and commit the result, so" >&2
echo "the official F-Droid listing shows this version's notes rather than" >&2
echo "the previous one's." >&2
exit 1
fi
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
+10
View File
@@ -110,6 +110,16 @@ jobs:
;;
esac
# Before a single Gradle task runs: F-Droid truncates the in-client
# changelog, so an over-long one would reach users cut off mid-sentence.
# The script exits non-zero past the limit. Cheap enough to sit in the
# gate job, where failing costs nothing and publishes nothing — the step
# further down that regenerates the file for the repo would otherwise be
# the first thing to notice, after the build and the signing.
- name: Changelog fits F-Droid
if: steps.v.outputs.is_release == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and
# re-uploads the existing index (recovery path).
+15
View File
@@ -7,6 +7,21 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
## [1.0.0] - 2026-09-21
### Added
- Agendula keeps your tasks itself now — nothing else to install. Manage lists
in the app, repeat tasks, and export any list as iCalendar.
- Settings → Storage picks where tasks live, and offers to copy them over from
OpenTasks or tasks.org.
### Changed
- Another task app is now optional. Already use one? Nothing changes on update.
### Fixed
- Edited due times no longer revert, all-day tasks no longer drift a day, and
per-task reminders are saved.
## [0.4.0] - 2026-08-31
### Added
+6
View File
@@ -38,6 +38,12 @@ apps, so there is no authority to clash over and no permission to grant. It
the other, and if you already sync through a provider, that keeps working exactly
as it did.
Switching between the two moves nothing — each store keeps its own tasks — so
Settings → Storage asks before it switches, and offers to **copy** a provider's
tasks into Agendula's own store when you want to move over. The copy is taken
once and the originals stay where they are; it is not an ongoing sync in either
direction.
Recurring tasks are expanded per RFC 5545, and everything the schema does not
model is round-tripped verbatim rather than dropped — so passing your tasks
through Agendula does not quietly lose fields a server sent.
+13 -16
View File
@@ -27,10 +27,11 @@ android {
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionCode = 400
versionName = "0.4.0"
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
// pipeline graduates it on its own. See docs/RELEASING.md.
versionCode = 10000
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -190,6 +191,7 @@ dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
@@ -202,9 +204,7 @@ dependencies {
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
// API surface, so it isn't declared separately.
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
@@ -225,17 +225,14 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation(libs.floret.core.time)
implementation(libs.floret.core.reminders)
implementation(libs.floret.core.locale)
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
+4 -10
View File
@@ -5,17 +5,11 @@
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". We pull Room in transitively via
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
# at startup, so the app died on launch in every minified build (issue #1).
# as "Failed to create an instance of ...". This first bit us through a
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
# issue #1); Glance is gone and Room is now our own task store, so the rule
# matters more, not less TasksDatabase is built on the first store read.
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# WorkManager likewise looks its workers up by name and calls this constructor
# reflectively same pruning, but it only bites once a worker actually runs
# (Glance's widget updates), so keep it explicitly rather than wait for it.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
@@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
@@ -319,12 +320,15 @@ class RoomTasksDataSourceTest {
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
// The whole reminder, not just the minute count: collapsing it to a bare
// Int is what fired an imported START-referenced alarm off DUE, and an
// alarm this seam sets from the UI is always due-referenced.
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(30)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(60)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
@@ -0,0 +1,317 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File
import javax.inject.Provider
import kotlin.time.Instant
/**
* The copy out of an external provider and into Room — the upgrade path every
* released install actually needs, since no release ever bundled the provider
* `OneShotImport` reads (see `docs/OWN-STORE.md`).
*
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
* worth testing is the write half — id remapping, uid collisions, verified
* counts, the once-only guard — and pinning that to a device with a third-party
* app installed would mean it never ran. Instrumented all the same, because the
* destination is a real Room database in a real transaction.
*/
@RunWith(AndroidJUnit4::class)
class ExternalImportTest {
@get:Rule
val temp = TemporaryFolder()
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var scope: CoroutineScope
private lateinit var prefs: DataStore<Preferences>
private lateinit var db: TasksDatabase
private lateinit var source: FakeExternalStore
private lateinit var importer: ExternalImport
@Before
fun setUp() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
prefs = PreferenceDataStoreFactory.create(scope = scope) {
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
}
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
.allowMainThreadQueries()
.build()
source = FakeExternalStore()
importer = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(NoProviderInstalled),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
}
@After
fun tearDown() {
db.close()
scope.cancel()
}
@Test
fun copiesListsTasksAndAlarms() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
val result = importer.run()
assertThat(result).isEqualTo(
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
)
assertThat(db.taskLists().lists().map { it.list.name })
.containsExactly("Errands", "Work")
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
.containsExactly("Milk", "Bread", "Invoice")
assertThat(importer.hasRun.first()).isTrue()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
importer.run()
assertThat(db.taskLists().lists().single().list.accountId).isNull()
}
/** Provider row ids are the source's; Room mints its own and the link follows. */
@Test
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
source.lists = listOf(list(7, "Errands"))
// Child before parent, so a naive single pass would not find the parent.
source.tasks = mapOf(
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
)
importer.run()
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
val parent = rows.single { it.title == "Parent" }
val child = rows.single { it.title == "Subtask" }
assertThat(child.parentId).isEqualTo(parent.id)
assertThat(child.parentId).isNotEqualTo(200L)
}
/**
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
* would reject it and take the whole copy down, so it gets a fresh uid.
*/
@Test
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(
7L to listOf(
task(100, "Weekly", uid = "shared-uid"),
task(101, "Weekly, that one week", uid = "shared-uid"),
),
)
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
assertThat(uids).hasSize(2)
assertThat(uids.toSet()).hasSize(2)
assertThat(uids).contains("shared-uid")
}
/** A START-referenced reminder must not come across as a before-due one. */
@Test
fun preservesTheAlarmReference() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
importer.run()
val alarm = db.alarms().all().single()
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
assertThat(alarm.minutesBefore).isEqualTo(10)
}
@Test
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
val result = importer.run()
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
assertThat(db.taskLists().lists()).isEmpty()
// Still on offer: there was nothing to copy, not a copy that happened.
assertThat(importer.hasRun.first()).isFalse()
}
/** A read that blows up must leave Room exactly as it was. */
@Test
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.failOnExport = true
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
assertThat(db.taskLists().lists()).isEmpty()
assertThat(importer.hasRun.first()).isFalse()
}
@Test
fun previewCountsWhatARunWouldWrite() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
// preview() resolves the provider itself, so it needs one to be installed.
val withProvider = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
assertThat(withProvider.preview())
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
}
@Test
fun previewIsNullWithoutAReadableProvider() = runBlocking {
assertThat(importer.preview()).isNull()
}
// --- fixtures --------------------------------------------------------------
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
id = id,
name = name,
color = 0xFF7E57C2.toInt(),
accountName = accountName,
accountType = "org.dmfs.account.LOCAL",
isSynced = true,
isVisible = true,
owner = null,
)
private fun task(
id: Long,
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
) = ExportTask(
taskId = id,
uid = uid,
title = title,
description = null,
location = null,
url = null,
priority = Priority.NONE,
status = TaskStatus.NEEDS_ACTION,
percentComplete = null,
start = null,
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
isAllDay = false,
completedAt = null,
created = null,
lastModified = null,
rrule = null,
rdate = null,
parentId = parentId,
)
private companion object {
var counter = 0
}
}
/** Only the three reads the copy makes; everything else is out of scope. */
private class FakeExternalStore : TasksDataSource {
var lists: List<TaskList> = emptyList()
var tasks: Map<Long, List<ExportTask>> = emptyMap()
var alarms: Map<Long, TaskReminder> = emptyMap()
var failOnExport = false
override fun taskLists(): List<TaskList> = lists
override fun exportTasks(listId: Long): List<ExportTask> {
if (failOnExport) error("provider went away mid-read")
return tasks[listId].orEmpty()
}
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
override fun deleteTask(taskId: Long) = unused()
override fun createLocalList(name: String, color: Int): Long = unused()
override fun updateList(listId: Long, name: String, color: Int) = unused()
override fun deleteList(listId: Long) = unused()
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
private fun unused(): Nothing = error("the copy does not call this")
}
/** No tasks provider on the device: `preview()` has nothing to read. */
private object NoProviderInstalled : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? =
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
override fun appLabel(packageName: String): String = "OpenTasks"
}
+11 -3
View File
@@ -113,16 +113,24 @@
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
tasks while Agendula is backgrounded. External mode only: in OWN mode
nothing outside the app can change our data, and Room's
InvalidationTracker covers our own writes. An intent-filter host must
be a literal, so both external authorities are listed. -->
InvalidationTracker covers our own writes.
An intent-filter host must be a literal, so both external authorities
are listed — one filter each. Two <data> tags in a single filter would
mean the same thing (Android takes the cross product of every data
attribute in a filter), but reads as if it might not, which is what
lint's IntentFilterUniqueDataAttributes warns about. -->
<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="org.tasks.opentasks" />
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
</intent-filter>
</receiver>
<!-- Sync plumbing. The stub provider exists only to give the sync
@@ -12,7 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
@@ -30,6 +30,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Provider
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
@@ -39,7 +40,10 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder
// A Provider, not the seeder itself: the call site below is dead in a release
// build (`BuildConfig.DEBUG` is a compile-time false), and injecting the
// instance would still construct one on every launch of the shipped app.
@Inject lateinit var demoSeeder: Provider<DemoSeeder>
@Inject lateinit var accounts: AccountRepository
@@ -93,7 +97,7 @@ class MainActivity : ComponentActivity() {
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
lifecycleScope.launch { runCatching { demoSeeder.get().seed() } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -103,6 +103,14 @@ abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
// Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is
// the active one.
@Binds
@Singleton
@ExternalStore
abstract fun bindExternalTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
}
@Module
@@ -48,3 +48,17 @@ annotation class CredentialsDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
* whichever store the active mode selects.
*
* Only the one-time copy into our own store needs to name a store this way;
* everything else goes through the routed source and must keep doing so. Having
* it as a binding rather than depending on the concrete class is also what lets
* that copy be tested against a fake.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ExternalStore
@@ -18,8 +18,9 @@ import javax.inject.Singleton
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/**
* Why an export failed, as a value rather than a message: the UI ships in eleven
* locales, so the wording has to come from a string resource.
* Why an export failed, as a value rather than a message: the UI is translated
* (see `res/xml/locales_config.xml`), so the wording has to come from a string
* resource rather than being built here.
*/
enum class ExportFailure {
FOLDER_UNAVAILABLE,
@@ -79,7 +79,6 @@ class TaskNotifier @Inject constructor(
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.net.Uri
import androidx.core.net.toUri
/**
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
@@ -150,7 +151,7 @@ object TasksContract {
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0
@@ -161,12 +162,12 @@ object TasksContract {
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
const val PRIORITY_NONE = 0
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
fun authorityUri(authority: String): Uri = "content://$authority".toUri()
fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri()
fun listUri(authority: String, listId: Long): Uri =
Uri.parse("content://$authority/${Lists.PATH}/$listId")
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
"content://$authority/${Lists.PATH}/$listId".toUri()
fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri()
fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri()
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
@@ -175,7 +176,7 @@ object TasksContract {
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
"content://$authority/${Instances.PATH}/$instanceId".toUri()
/** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks.legacy
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -68,6 +69,17 @@ class OneShotImport @Inject constructor(
/** Whether the import has run. Set before the rename, so both guards hold. */
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
/**
* Whether the last attempt failed and the archived source is still sitting
* there unread.
*
* Recorded because the alternative is what this class used to do: return an
* [ImportResult.Failed] that every caller dropped on the floor, leaving an
* upgrading user with an empty app, no message, and their tasks in a file
* only a developer could find. Settings → Storage offers the retry.
*/
val lastAttemptFailed: Flow<Boolean> = dataStore.data.map { it[IMPORT_FAILED] ?: false }
/**
* Steps 18 of the plan: archive `databases/tasks.db`, import it, record
* completion. Safe to call on every launch.
@@ -94,7 +106,7 @@ class OneShotImport @Inject constructor(
return@withContext ImportResult.NothingToImport
}
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext ImportResult.Failed(it) }
.getOrElse { return@withContext recordFailure(it) }
markDone()
ImportResult.Imported(counts)
}
@@ -107,7 +119,7 @@ class OneShotImport @Inject constructor(
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext ImportResult.Failed(it) }
.getOrElse { return@withContext recordFailure(it) }
markDone()
ImportResult.Imported(counts)
}
@@ -341,8 +353,22 @@ class OneShotImport @Inject constructor(
return true
}
/**
* Leaves a breadcrumb the UI can act on, and one in logcat for a bug report.
* The completion flag is deliberately *not* set: the next launch retries on
* its own, and the archived source is still where it was.
*/
private suspend fun recordFailure(cause: Throwable): ImportResult.Failed {
Log.e(TAG, "Importing the legacy task database failed; source left in place", cause)
dataStore.edit { it[IMPORT_FAILED] = true }
return ImportResult.Failed(cause)
}
private suspend fun markDone() {
dataStore.edit { it[IMPORT_DONE] = true }
dataStore.edit {
it[IMPORT_DONE] = true
it.remove(IMPORT_FAILED)
}
}
private fun CursorColumnReader.instant(name: String): Instant? =
@@ -356,6 +382,8 @@ class OneShotImport @Inject constructor(
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
private val IMPORT_FAILED = booleanPreferencesKey("legacy_import_failed")
private const val TAG = "OneShotImport"
}
}
@@ -0,0 +1,277 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import de.jeanlucmakiola.agendula.data.di.ExternalStore
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/** How much one copy moved. */
data class TransferCounts(val lists: Int, val tasks: Int, val alarms: Int)
/** The outcome of [ExternalImport.run]. */
sealed interface TransferResult {
/** The external store holds no list to copy — nothing was written. */
data object NothingToCopy : TransferResult
data class Copied(val counts: TransferCounts) : TransferResult
/** Nothing landed: the transaction rolled back and the source is untouched. */
data class Failed(val cause: Throwable) : TransferResult
}
/**
* Copies an external provider's tasks (OpenTasks, tasks.org) into Agendula's own
* Room store, once, when the user asks for it in Settings → Storage.
*
* This is the upgrade path 1.0.0 needs and [de.jeanlucmakiola.agendula.data
* .tasks.legacy.OneShotImport] does not provide: that one moves a *bundled dmfs
* provider's* SQLite file, which no released version ever shipped, so every
* existing install's tasks are in a third-party provider instead. Without this
* the only way onto the new store is to retype everything by hand.
*
* **A copy, not a sync, and deliberately one-directional.** The source is left
* exactly as it is — whatever syncs it (DAVx5 and friends) keeps doing so, and
* the two sets of rows drift apart from the moment this finishes. The reverse
* direction is not offered: writing a *list* into a third-party provider means
* impersonating its sync adapter, and the external store is already the one that
* can sync.
*
* **What does not come across**, because the read seam is
* [TasksDataSource.exportTasks] and that is shaped for iCalendar output:
* per-occurrence `RECURRENCE-ID` overrides (a series arrives as its master plus
* its rule, so an edited single occurrence reverts to the series' own values),
* `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact
* `PRIORITY` digit — [de.jeanlucmakiola.agendula.domain.Priority] buckets 14 as
* HIGH, so a `PRIORITY:3` lands as `1`. Nothing the app itself displays is lost.
*
* Task `uid`s *are* preserved, which is what lets these rows be re-attached to a
* CalDAV collection once sync lands rather than duplicating server-side.
*/
@Singleton
class ExternalImport @Inject constructor(
@ExternalStore private val external: Provider<TasksDataSource>,
private val resolver: ProviderResolver,
private val database: TasksDatabase,
private val dataStore: DataStore<Preferences>,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Whether a copy has already succeeded. The UI uses this to stop offering the
* action, because a second run would duplicate every row: the rows it writes
* are ordinary tasks afterwards, indistinguishable from ones typed by hand, so
* there is nothing to reconcile a re-run against.
*/
val hasRun: Flow<Boolean> = dataStore.data.map { it[TRANSFER_DONE] ?: false }
/**
* What a copy would move, for the confirmation to name real numbers — or
* `null` when there is no readable external provider to copy from.
*
* Counts masters, the same rows [run] writes, so the number the user agrees to
* is the number they get.
*/
suspend fun preview(): TransferCounts? = withContext(io) {
val provider = resolver.resolveExternal() ?: return@withContext null
if (!resolver.hasPermission(provider)) return@withContext null
runCatching {
val source = external.get()
val lists = source.taskLists()
val tasks = lists.sumOf { source.exportTasks(it.id).size }
TransferCounts(lists = lists.size, tasks = tasks, alarms = source.alarms().size)
}.getOrNull()
}
/**
* Reads the external store, then writes everything into Room in **one
* transaction with verified counts** — the same discipline as the legacy
* import, for the same reason: a partial copy is worse than none, because the
* user cannot tell which half is missing.
*
* The flag is written only after the transaction commits. A crash in between
* leaves the rows in place and the action still on offer, which duplicates on
* a second run — the lesser of the two evils, since the alternative is
* claiming a copy that never happened.
*/
suspend fun run(): TransferResult = withContext(io) {
val snapshot = runCatching { read() }
.getOrElse { return@withContext TransferResult.Failed(it) }
if (snapshot.lists.isEmpty()) return@withContext TransferResult.NothingToCopy
val counts = runCatching {
database.runInTransaction<TransferCounts> {
val baseline = tableCounts()
val written = write(snapshot)
verify(written, baseline)
written
}
}.getOrElse { return@withContext TransferResult.Failed(it) }
markDone()
TransferResult.Copied(counts)
}
// --- reading the external provider ----------------------------------------
private fun read(): Snapshot {
val source = external.get()
val lists = source.taskLists()
return Snapshot(
lists = lists,
tasksByList = lists.associate { it.id to source.exportTasks(it.id) },
alarms = source.alarms(),
)
}
// --- writing into Room ----------------------------------------------------
/**
* Provider row ids are the source's own, and Room mints its own on insert, so
* `parentId` is remapped through the ids the inserts hand back. Tasks go in
* with their parent cleared and a second pass sets it, because a parent may
* sort after its child.
*
* Every list arrives as **device-only** (`account_id IS NULL`), including one
* that sat under a CalDAV account in the provider: the account belongs to the
* sync app, not to us, and claiming it here would suggest Agendula syncs it.
*/
private fun write(snapshot: Snapshot): TransferCounts {
val listDao = database.taskLists()
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { list ->
list.id to listDao.insert(
TaskListEntity(
name = list.name,
color = list.color,
accountId = null,
isVisible = list.isVisible,
isSynced = false,
owner = list.owner,
),
)
}
val taskIds = mutableMapOf<Long, Long>()
val inserted = mutableListOf<Pair<ExportTask, TaskEntity>>()
// A `RECURRENCE-ID` override reaches us as another master-shaped row
// sharing its series' UID, which the unique index would reject and take
// the whole copy down with it. A fresh uid costs that row nothing it
// still has.
val seen = mutableSetOf<Pair<Long, String>>()
for ((sourceListId, tasks) in snapshot.tasksByList) {
val listId = listIds[sourceListId] ?: continue
for (task in tasks) {
val uid = task.uid?.takeIf { seen.add(listId to it) } ?: UUID.randomUUID().toString()
val entity = task.toEntity(listId, uid)
val newId = taskDao.insert(entity)
taskIds[task.taskId] = newId
inserted += task to entity.copy(id = newId)
}
}
for ((task, entity) in inserted) {
val parentId = task.parentId?.let(taskIds::get) ?: continue
taskDao.update(entity.copy(parentId = parentId))
}
var alarmCount = 0
for ((sourceTaskId, reminder) in snapshot.alarms) {
val taskId = taskIds[sourceTaskId] ?: continue
alarmDao.insert(reminder.toEntity(taskId))
alarmCount++
}
return TransferCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
}
private fun ExportTask.toEntity(listId: Long, uid: String) = TaskEntity(
listId = listId,
uid = uid,
title = title,
description = description,
location = location,
url = url,
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
priority = priority.toICal(),
dtstart = start,
due = due,
isAllDay = isAllDay,
rrule = rrule,
rdate = rdate,
createdAt = created,
lastModified = lastModified,
)
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
private fun verify(written: TransferCounts, before: TransferCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
}
check(after.tasks - before.tasks == written.tasks) {
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
}
check(after.alarms - before.alarms == written.alarms) {
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
}
}
private fun tableCounts() = TransferCounts(
lists = count("task_lists"),
tasks = count("tasks"),
alarms = count("task_alarms"),
)
private fun count(table: String): Int =
database.query("SELECT COUNT(*) FROM $table", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
private suspend fun markDone() {
dataStore.edit { it[TRANSFER_DONE] = true }
}
/** Clears the guard so the action is offered again. Test and support hook. */
suspend fun clearCompletion() {
dataStore.edit { it.remove(TRANSFER_DONE) }
}
private class Snapshot(
val lists: List<TaskList>,
val tasksByList: Map<Long, List<ExportTask>>,
val alarms: Map<Long, TaskReminder>,
)
private companion object {
val TRANSFER_DONE = booleanPreferencesKey("external_copy_done")
}
}
@@ -20,7 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.common.OnResume
@@ -36,7 +36,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
@@ -65,7 +65,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task
@@ -78,7 +78,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.time.DayWindow
@@ -47,7 +47,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.export.ExportFailure
@@ -72,7 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Priority
@@ -8,7 +8,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@@ -26,7 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.export.ExportOutcome
@@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
@@ -38,7 +38,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
@@ -7,12 +7,17 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.data.tasks.transfer.ExternalImport
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride
@@ -46,6 +51,38 @@ data class StorageUiState(
val external: TaskProvider? = null,
/** That provider's own app name, for a row that names what it is switching to. */
val externalLabel: String? = null,
/**
* Whether copying that provider's tasks into our own store is still on offer:
* a provider is installed and permitted, and no copy has succeeded yet.
*/
val canCopyFromExternal: Boolean = false,
/**
* Whether the one-shot legacy import failed and is still retryable. Almost
* always false: the bundled provider it reads from never shipped in a release.
*/
val legacyImportFailed: Boolean = false,
)
/** How the last copy ended — kept on screen rather than flashed past. */
sealed interface TransferOutcome {
data class Copied(val counts: TransferCounts) : TransferOutcome
data object NothingToCopy : TransferOutcome
data object Failed : TransferOutcome
}
/**
* The copy-from-external flow: a confirmation that names real numbers, then the
* run, then a receipt that stays put.
*
* [preview] is null while the counts are still being read — the dialog opens
* first and fills in, because counting means querying every list in the provider
* and that is not instant on a big store.
*/
data class TransferUiState(
val confirming: Boolean = false,
val preview: TransferCounts? = null,
val running: Boolean = false,
val outcome: TransferOutcome? = null,
)
/**
@@ -57,6 +94,9 @@ class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
private val environment: ProviderEnvironment,
private val externalImport: ExternalImport,
private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler,
@IoDispatcher io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
@@ -81,7 +121,12 @@ class SettingsViewModel @Inject constructor(
// name the wrong store for the first frames. flowOn, because every field here
// costs a PackageManager lookup or a permission check.
val storage: StateFlow<StorageUiState?> =
combine(prefs.storageMode, providerProbe) { stored, _ ->
combine(
prefs.storageMode,
providerProbe,
externalImport.hasRun,
oneShotImport.lastAttemptFailed,
) { stored, _, copied, legacyFailed ->
val external = resolver.resolveExternal()
StorageUiState(
// No stored choice is the normal state; show what autoMode resolves
@@ -89,6 +134,10 @@ class SettingsViewModel @Inject constructor(
mode = stored ?: resolver.autoMode(),
external = external,
externalLabel = external?.packageName?.let(environment::appLabel),
canCopyFromExternal = !copied &&
external != null &&
resolver.hasPermission(external),
legacyImportFailed = legacyFailed,
)
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -97,6 +146,69 @@ class SettingsViewModel @Inject constructor(
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
// --- copying an external provider's tasks into our own store ---------------
private val transferState = MutableStateFlow(TransferUiState())
val transfer: StateFlow<TransferUiState> = transferState
/** Open the confirmation and start counting what a copy would move. */
fun startCopyFromExternal() {
if (transferState.value.running) return
transferState.update { it.copy(confirming = true, preview = null, outcome = null) }
viewModelScope.launch {
val counts = externalImport.preview()
// The dialog may already be gone — dismissing while the count runs is
// the normal way out of a store too big to count quickly.
transferState.update { if (it.confirming) it.copy(preview = counts) else it }
}
}
fun dismissCopyFromExternal() = transferState.update { it.copy(confirming = false) }
/**
* Run the copy, then re-arm reminders: the tasks that just landed carry their
* own alarm rows, and nothing else would notice them — the scheduler is driven
* by explicit syncs, not by a store observer.
*/
fun confirmCopyFromExternal() {
if (transferState.value.running) return
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
viewModelScope.launch {
val result = externalImport.run()
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
transferState.update {
it.copy(
running = false,
outcome = when (result) {
is TransferResult.Copied -> TransferOutcome.Copied(result.counts)
TransferResult.NothingToCopy -> TransferOutcome.NothingToCopy
is TransferResult.Failed -> TransferOutcome.Failed
},
)
}
}
}
/**
* Retry the legacy import against the archived `tasks.db.imported`. Truncates
* and replaces rather than merging, so tapping twice cannot double-import; on
* success the flag clears and the row offering this disappears with it.
*/
fun retryLegacyImport() {
if (retryingLegacyImport.value) return
retryingLegacyImport.value = true
viewModelScope.launch {
runCatching { oneShotImport.reimportFromArchive() }
runCatching { reminderScheduler.sync() }
retryingLegacyImport.value = false
}
}
private val retryingLegacyImport = MutableStateFlow(false)
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
@@ -5,22 +5,34 @@ import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.MoveDown
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -28,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
@@ -47,8 +60,13 @@ internal fun StorageScreen(
) {
val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle()
val transfer by viewModel.transfer.collectAsStateWithLifecycle()
val retryingLegacyImport by viewModel.legacyImportRetrying.collectAsStateWithLifecycle()
var showPicker by remember { mutableStateOf(false) }
var denied by remember { mutableStateOf(false) }
// The store the user picked and has not yet confirmed. Switching stores moves
// nothing, so it is worth one dialog rather than an empty app and a guess.
var pendingMode by remember { mutableStateOf<StorageMode?>(null) }
// The mode is committed only once the grant is in — switching first drops the
// user on the app-wide permission gate.
@@ -80,6 +98,19 @@ internal fun StorageScreen(
}
},
)
if (storage?.canCopyFromExternal == true) {
GroupedRow(
title = stringResource(
R.string.settings_copy_from_external,
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
),
summary = stringResource(R.string.settings_copy_from_external_hint),
position = Position.Middle,
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
// Inert while a copy is in flight; the receipt below says so.
onClick = if (transfer.running) null else viewModel::startCopyFromExternal,
)
}
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
@@ -87,6 +118,26 @@ internal fun StorageScreen(
onClick = onOpenExport,
)
TransferStatus(transfer)
// Only ever shown to someone whose upgrade import failed — their tasks are
// still in the archived file, and this is the one way back to them.
if (storage?.legacyImportFailed == true) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_legacy_import_failed),
summary = stringResource(
if (retryingLegacyImport) {
R.string.settings_legacy_import_retrying
} else {
R.string.settings_legacy_import_failed_hint
},
),
position = Position.Alone,
onClick = if (retryingLegacyImport) null else viewModel::retryLegacyImport,
)
}
if (denied) {
Spacer(Modifier.height(16.dp))
GroupedRow(
@@ -102,7 +153,21 @@ internal fun StorageScreen(
if (showPicker) {
StorePicker(
storage = state,
onSelect = { mode ->
// Picking the store already in use is not a switch — no dialog.
onSelect = { mode -> if (mode != state.mode) pendingMode = mode },
onDismiss = { showPicker = false },
)
}
pendingMode?.let { mode ->
SwitchStoreDialog(
target = mode,
otherStore = when (mode) {
StorageMode.OWN -> externalTitle(state.external, state.externalLabel)
StorageMode.EXTERNAL -> stringResource(R.string.settings_store_own)
},
onConfirm = {
pendingMode = null
val external = state.external
if (mode == StorageMode.EXTERNAL && external != null) {
// Asked even when the grant looks held: an already-granted
@@ -114,12 +179,146 @@ internal fun StorageScreen(
viewModel.setStorageMode(mode)
}
},
onDismiss = { showPicker = false },
onDismiss = { pendingMode = null },
)
}
if (transfer.confirming) {
CopyFromExternalDialog(
source = externalTitle(state.external, state.externalLabel),
counts = transfer.preview,
onConfirm = viewModel::confirmCopyFromExternal,
onDismiss = viewModel::dismissCopyFromExternal,
)
}
}
}
/**
* The switch itself, behind a confirm. Neither store hands its rows to the other
* when the mode changes — the tasks stay where they were written — so the app
* looks emptied to anyone who expected a move. Saying so once is cheaper than
* the support thread.
*/
@Composable
private fun SwitchStoreDialog(
target: StorageMode,
otherStore: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) },
title = { Text(stringResource(R.string.settings_store_switch_title)) },
text = {
Text(
stringResource(
when (target) {
StorageMode.OWN -> R.string.settings_store_switch_to_own
StorageMode.EXTERNAL -> R.string.settings_store_switch_to_external
},
otherStore,
),
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.settings_store_switch_confirm)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/**
* The copy, behind a confirm that names real numbers — it is irreversible in the
* only sense that matters: nothing merges the result back, so running it twice
* would leave two of everything. Confirm stays disabled until the count lands.
*/
@Composable
private fun CopyFromExternalDialog(
source: String,
counts: TransferCounts?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
title = { Text(stringResource(R.string.settings_copy_confirm_title)) },
text = {
Text(
text = when {
counts == null -> stringResource(R.string.settings_copy_counting)
counts.tasks == 0 -> stringResource(R.string.settings_copy_confirm_empty, source)
else -> pluralStringResource(
R.plurals.settings_copy_confirm_message,
counts.tasks,
counts.tasks,
source,
)
},
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = counts != null && counts.tasks > 0) {
Text(stringResource(R.string.settings_copy_confirm_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/** The running spinner, then whatever the last copy ended as — it stays put. */
@Composable
private fun TransferStatus(state: TransferUiState) {
val outcome = state.outcome
when {
state.running -> Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(Modifier.size(18.dp))
Text(
text = stringResource(R.string.settings_copy_running),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
outcome is TransferOutcome.Copied -> StatusText(
text = pluralStringResource(
R.plurals.settings_copy_done,
outcome.counts.tasks,
outcome.counts.tasks,
outcome.counts.lists,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome is TransferOutcome.NothingToCopy -> StatusText(
text = stringResource(R.string.settings_copy_nothing),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome is TransferOutcome.Failed -> StatusText(
text = stringResource(R.string.settings_copy_failed),
color = MaterialTheme.colorScheme.error,
)
}
}
@Composable
private fun StatusText(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = color,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
/**
* The two stores, as rows. External is offered only when a provider is actually
* installed — dimmed and inert otherwise, because a mode with nothing behind it
@@ -87,7 +87,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.time.DayWindow
@@ -204,7 +204,7 @@ fun TaskListScreen(
},
bottomBar = {
if (showBottomAddBar) {
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
},
) { inner ->
-11
View File
@@ -15,9 +15,6 @@
<string name="smart_completed">Abgeschlossen</string>
<string name="task_detail_title">Aufgabe</string>
<string name="task_detail_not_found">Diese Aufgabe ist nicht mehr verfügbar.</string>
<string name="edit_task_title">Aufgabe bearbeiten</string>
<string name="new_task_title">Neue Aufgabe</string>
<string name="field_title">Titel</string>
<string name="edit_save_failed">Konnte nicht gespeichert werden. Versuche es erneut.</string>
<string name="add_task_hint">Aufgabe hinzufügen</string>
<string name="add_subtask_hint">Unteraufgabe hinzufügen</string>
@@ -36,11 +33,8 @@
<string name="priority_low">Niedrig</string>
<string name="priority_medium">Mittel</string>
<string name="priority_high">Hoch</string>
<string name="detail_mark_complete">Als abgeschlossen markieren</string>
<string name="detail_mark_incomplete">Als nicht abgeschlossen markieren</string>
<string name="detail_list">Liste</string>
<string name="detail_due">Fällig</string>
<string name="detail_start">Beginn</string>
<string name="detail_priority">Priorität</string>
<string name="detail_progress">Fortschritt</string>
<string name="detail_subtasks">Unteraufgaben</string>
@@ -98,7 +92,6 @@
<string name="smart_overdue">Überfällig</string>
<string name="smart_upcoming">Anstehend</string>
<string name="smart_all">Alle</string>
<string name="open_count">%1$d offen</string>
<string name="home_today_progress">%1$d von %2$d erledigt</string>
<string name="home_today_remaining">%1$d übrig</string>
<string name="home_today_all_done">Alles erledigt 🎉</string>
@@ -108,7 +101,6 @@
<string name="home_due_tomorrow">Morgen</string>
<string name="home_search_hint">Aufgaben suchen</string>
<string name="home_search_clear">Suche zurücksetzen</string>
<string name="home_search_close">Suche schließen</string>
<string name="home_search_empty">Keine Aufgaben passen zu „%1$s“</string>
<string name="reminder_due_at">Fälligkeiten %1$s</string>
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
@@ -118,8 +110,6 @@
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
<string name="onboarding_permission_button">Zugriff gewähren</string>
<string name="onboarding_install_opentasks">Installiere OpenTasks</string>
<string name="onboarding_install_tasksorg">Installiere tasks.org</string>
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
@@ -162,7 +152,6 @@
<string name="settings_exact_alarms">Exakter Zeitpunkt</string>
<string name="settings_exact_alarms_allowed">Die Erinnerungen erfolgen zum exakten Zeitpunkt</string>
<string name="settings_exact_alarms_blocked">Blockiert tippe, um exakte Erinnerungen zu erlauben</string>
<string name="settings_section_tasks">Aufgaben</string>
<string name="settings_default_list">Standard-Liste</string>
<string name="settings_default_list_first">Erste verfügbare Felder</string>
<string name="settings_add_subtask_row">Zeile \"Unteraufgaben hinzufügen\" anzeigen</string>
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Agendula</string>
<string name="app_tagline">Um app de tarefas em Material 3 Expressive. \nFlorescendo.</string>
<string name="task_untitled">Tarefa sem título</string>
<string name="back">Voltar</string>
<string name="save">Salvar</string>
@@ -14,9 +13,6 @@
<string name="task_detail_title">Tarefa</string>
<string name="task_detail_not_found">Esta tarefa não existe mais.</string>
<string name="edit">Editar</string>
<string name="edit_task_title">Editar tarefa</string>
<string name="new_task_title">Nova tarefa</string>
<string name="field_title">Título</string>
<string name="field_description">Descrição</string>
<string name="edit_save_failed">Não foi possível salvar. Tente novamente.</string>
<string name="add_task_hint">Adicionar tarefa</string>
@@ -37,11 +33,8 @@
<string name="priority_low">Baixa</string>
<string name="priority_medium">Média</string>
<string name="priority_high">Alta</string>
<string name="detail_mark_complete">Marcar como concluído</string>
<string name="detail_mark_incomplete">Marcar como não concluído</string>
<string name="detail_list">Lista</string>
<string name="detail_due">Prazo</string>
<string name="detail_start">Inicia</string>
<string name="detail_priority">Prioridade</string>
<string name="detail_progress">Progresso</string>
<string name="detail_subtasks">Subtarefas</string>
@@ -100,7 +93,6 @@
<string name="smart_overdue">Prazo expirado</string>
<string name="smart_upcoming">A seguir</string>
<string name="smart_all">Tudo</string>
<string name="open_count">%1$d abertos</string>
<string name="home_today_progress">%1$d de %2$d concluídos</string>
<string name="home_today_remaining">%1$d restantes</string>
<string name="home_today_all_done">Tudo pronto 🎉</string>
@@ -110,7 +102,6 @@
<string name="home_due_tomorrow">Amanhã</string>
<string name="home_search_hint">Buscar tarefas</string>
<string name="home_search_clear">Limpar buscas</string>
<string name="home_search_close">Fechar busca</string>
<string name="home_search_empty">Nenhuma tarefa corresponde a \"%1$s\"</string>
<string name="reminder_due_at">Termina em %1$s</string>
<string name="reminder_channel_name">Lembretes de tarefas</string>
@@ -120,8 +111,6 @@
<string name="onboarding_permission_title">Permitir acesso às suas tarefas</string>
<string name="onboarding_permission_body">O Agendula precisa de permissão para ler e salvar suas tarefas. É tudo o que ele pede.</string>
<string name="onboarding_permission_button">Conceder acesso às tarefas</string>
<string name="onboarding_install_opentasks">Instalar OpenTasks</string>
<string name="onboarding_install_tasksorg">Instalar tasks.org</string>
<string name="reminder_onboarding_title">Nunca perca seus prazos</string>
<string name="reminder_onboarding_body">Apps de tarefas não enviam lembretes sozinhos, então o Agendula os entrega para você. Ative para receber uma notificação de quando uma tarefa estiver no prazo.</string>
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
@@ -166,7 +155,6 @@
<string name="settings_exact_alarms">Na hora</string>
<string name="settings_exact_alarms_allowed">Lembretes disparam na mesma hora</string>
<string name="settings_exact_alarms_blocked">Bloqueado — toque para permitir lembretes na hora</string>
<string name="settings_section_tasks">Tarefas</string>
<string name="settings_default_list">Lista padrão</string>
<string name="settings_default_list_first">Primeira lista disponível</string>
<string name="settings_add_subtask_row">Mostrar uma linha \"adicionar subtarefa\"</string>
-1
View File
@@ -1,6 +1,5 @@
<resources>
<!-- Seed color: warm mauve. Material 3 derives Light & Dark schemes from this. -->
<color name="seed">#FF7A5C6B</color>
<!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF7A5C6B</color>
</resources>
+27 -13
View File
@@ -1,6 +1,5 @@
<resources>
<string name="app_name">Agendula</string>
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
@@ -21,9 +20,6 @@
<string name="edit">Edit</string>
<!-- Task edit -->
<string name="edit_task_title">Edit task</string>
<string name="new_task_title">New task</string>
<string name="field_title">Title</string>
<string name="field_description">Description</string>
<string name="edit_save_failed">Could not save. Try again.</string>
@@ -52,11 +48,8 @@
<string name="priority_high">High</string>
<!-- Task detail -->
<string name="detail_mark_complete">Mark complete</string>
<string name="detail_mark_incomplete">Mark not complete</string>
<string name="detail_list">List</string>
<string name="detail_due">Due</string>
<string name="detail_start">Starts</string>
<string name="detail_priority">Priority</string>
<string name="detail_progress">Progress</string>
<string name="detail_subtasks">Subtasks</string>
@@ -160,7 +153,6 @@
<string name="smart_overdue">Overdue</string>
<string name="smart_upcoming">Upcoming</string>
<string name="smart_all">All</string>
<string name="open_count">%1$d open</string>
<!-- Home: Today progress hero -->
<string name="home_today_progress">%1$d of %2$d done</string>
@@ -176,7 +168,6 @@
<!-- Home: search -->
<string name="home_search_hint">Search tasks</string>
<string name="home_search_clear">Clear search</string>
<string name="home_search_close">Close search</string>
<string name="home_search_empty">No tasks match “%1$s”</string>
<!-- Reminders -->
@@ -190,8 +181,6 @@
<string name="onboarding_permission_title">Allow access to your tasks</string>
<string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
<string name="onboarding_permission_button">Grant task access</string>
<string name="onboarding_install_opentasks">Install OpenTasks</string>
<string name="onboarding_install_tasksorg">Install tasks.org</string>
<!-- First run: welcome -->
<string name="onboarding_welcome_title">Everything you meant to do</string>
@@ -304,7 +293,6 @@
<string name="settings_exact_alarms">Exact timing</string>
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_section_tasks">Tasks</string>
<string name="settings_default_list">Default list</string>
<string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
@@ -318,7 +306,7 @@
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
@@ -326,6 +314,32 @@
<string name="settings_store_external_missing">No compatible task app is installed</string>
<string name="settings_store_permission_denied">Permission denied</string>
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
<!-- Switching stores, and copying tasks from another task app into ours -->
<string name="settings_store_switch_title">Switch task store?</string>
<string name="settings_store_switch_to_own">Your tasks stay in %1$s — they are not moved. Agendula will show its own storage, which starts out empty unless you copy them over.</string>
<string name="settings_store_switch_to_external">Your tasks stay in %1$s — they are not moved. Agendula will show the other app\'s tasks instead.</string>
<string name="settings_store_switch_confirm">Switch</string>
<string name="settings_copy_from_external">Copy tasks from %1$s</string>
<string name="settings_copy_from_external_hint">Bring them into Agendula\'s own storage. A one-time copy — the originals stay where they are.</string>
<string name="settings_copy_confirm_title">Copy tasks over?</string>
<string name="settings_copy_counting">Counting what there is to copy…</string>
<string name="settings_copy_confirm_empty">%1$s holds no tasks to copy.</string>
<plurals name="settings_copy_confirm_message">
<item quantity="one">%1$d task from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once.</item>
<item quantity="other">%1$d tasks from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once.</item>
</plurals>
<string name="settings_copy_confirm_action">Copy</string>
<string name="settings_copy_running">Copying tasks…</string>
<plurals name="settings_copy_done">
<item quantity="one">Copied %1$d task into %2$d list. Pick “On this device” above to see them.</item>
<item quantity="other">Copied %1$d tasks into %2$d lists. Pick “On this device” above to see them.</item>
</plurals>
<string name="settings_copy_nothing">There was nothing to copy</string>
<string name="settings_copy_failed">The tasks could not be copied. Nothing was changed — your originals are untouched.</string>
<string name="settings_legacy_import_failed">Your earlier tasks could not be moved</string>
<string name="settings_legacy_import_failed_hint">They are still saved and nothing was lost. Tap to try moving them again.</string>
<string name="settings_legacy_import_retrying">Moving them now…</string>
<string name="settings_export">Export tasks</string>
<string name="settings_export_hint">Save your lists as iCalendar files</string>
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>
+43
View File
@@ -332,6 +332,17 @@ gain.
## Migrating existing users
> ⚠️ **Corrected, 2026-09-21.** The premise below is wrong, and it was wrong when
> it was written: **no release ever bundled the provider.** It was added and
> deleted inside this one unreleased cycle, so `databases/tasks.db` exists on no
> published install and `OneShotImport` finds nothing to do for every real user.
> Anyone on 0.3.x or 0.4.0 keeps their tasks in OpenTasks or tasks.org, which
> `ProviderResolver.autoMode()` correctly keeps them on. The path onto the new
> store for those users is `ExternalImport` — see
> [Copying from an external provider](#copying-from-an-external-provider) below.
> Everything in this section still applies to a device that ran a dev build of
> this branch, which is why the import, its fixture and its tests stay.
Anyone on v0.3.x has their tasks inside the bundled provider's SQLite file at
`/data/data/de.jeanlucmakiola.agendula/databases/tasks.db` (dmfs schema
version 23). Removing the Gradle module does **not** remove that file — an app
@@ -384,6 +395,38 @@ still exist:
3. Only after a release with no import defects reported does a subsequent
version delete `tasks.db.imported`.
A failure is no longer invisible, either. `runIfNeeded` used to hand back an
`ImportResult.Failed` that `StartupGate` discarded — an upgrading user got an
empty app, no message, and their tasks in a file only a developer could name. The
outcome is now recorded in DataStore and logged, and Settings → Storage offers the
retry, which is what makes `reimportFromArchive()` reachable at all.
### Copying from an external provider
The migration that actually matters for 1.0.0, since the one above serves nobody.
`ExternalImport` (`data/tasks/transfer/`) reads the external provider through the
existing `exportTasks` seam and writes lists, tasks and alarms into Room in one
transaction with verified counts — the same shape as `OneShotImport`, for the
same reason. Task `uid`s are preserved, so the rows can be attached to a CalDAV
collection once sync lands rather than duplicating server-side.
- **User-initiated, once.** Settings → Storage → *Copy tasks from …*, behind a
confirm that names the real task count. Offered only while a provider is
installed and permitted and no copy has succeeded; a second run would duplicate
everything, because what it writes is indistinguishable from hand-typed tasks
the moment it finishes.
- **A copy, not a sync.** The source is untouched, whatever syncs it keeps
syncing it, and the two sets drift from that moment. Switching stores now asks
first for the same reason: neither store hands its rows to the other.
- **One-directional.** Writing a *list* into a third-party provider means
impersonating its sync adapter, and the external store is already the one that
can sync.
- **What does not come across**, because the read seam is shaped for iCalendar
output: per-occurrence `RECURRENCE-ID` overrides (a series arrives as master +
rule), `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact
`PRIORITY` digit (`Priority` buckets 14 as HIGH). Nothing the app itself
displays is lost.
This is the reason phase 5 (deleting `:provider`) ships *after* phase 4 rather
than with it — and the reason the deletion is its own release.
+16 -7
View File
@@ -1,7 +1,7 @@
---
title: Privacy Policy — Agendula
description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself.
updated: 2026-09-09
updated: 2026-09-21
---
<!--
@@ -21,7 +21,7 @@ updated: 2026-09-09
render on the published page.
-->
**Last updated:** 9 September 2026
**Last updated:** 21 September 2026
Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`),
all versions and all distribution channels.
@@ -37,7 +37,7 @@ nothing and no one else. Nothing is ever sent to the developer.
IT-Dienstleister | Jean-Luc Makiola
Mahlerstraße 10
14772 Brandenburg an der Havel
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
## 2. No data collection by the developer
@@ -144,9 +144,13 @@ stored locally on your device and are removed when you uninstall the app.
If Android Auto Backup is enabled on your device, your tasks and settings may
be backed up to your own Google account, under Google's terms — the developer
has no access to it. Two things are deliberately excluded from that backup:
your stored CalDAV password, and Agendula's per-device sync bookkeeping. After
restoring onto a new device you therefore sign in to your server again.
has no access to it. What travels is Agendula's own task database and your
settings, and nothing else: the backup rules name those explicitly, which makes
everything not named — including the archived copy the app keeps of an older
version's database — excluded by default. Two things are deliberately kept out
of that backup: your stored CalDAV password, and Agendula's per-device sync
bookkeeping. After restoring onto a new device you therefore sign in to your
server again.
## 7. Crash reports
@@ -183,6 +187,10 @@ process — it only opens the address.
## 9. Permissions and why they exist
This is the complete list the released app declares — you can check it against
the app's entry in F-Droid, or against `app/src/main/AndroidManifest.xml` in the
source:
- `INTERNET`, `ACCESS_NETWORK_STATE` — CalDAV sync with the server you
configure, and checking whether a connection exists before trying. Without a
CalDAV account, no connection is made.
@@ -195,7 +203,8 @@ process — it only opens the address.
- `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` and
`org.tasks.permission.READ_TASKS` / `WRITE_TASKS` — optional, requested only
if you choose the external-provider storage mode, and only for the provider
you selected (OpenTasks or tasks.org).
you selected (OpenTasks or tasks.org). All four are declared in the manifest
because a manifest is static, but none is requested until you pick that mode.
- `WAKE_LOCK`, `FOREGROUND_SERVICE` — required by the Android system component
used for scheduled background work (WorkManager); on older Android versions
it needs them to run an expedited sync.
+74 -15
View File
@@ -126,10 +126,14 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
reached by the gear on the lists overview). Covers theme, dynamic colour, due
reminders (toggle + default offset + exact-alarm status), default list, and the
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
are translations to switch to).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
add-a-subtask-row opt-out — plus the **App language** picker, which landed with
the 0.4.0 translations.
- ⬜ Glance task-list widget — still just an idea, and now without a foothold:
the `glance-appwidget` / `glance-material3` dependencies were declared but never
used by a single line, so they were shipping dead weight in the APK and have
been removed. Re-add them with the implementation, not before.
- ✅ Translations — German and Brazilian Portuguese, via Weblate, shipped in
0.4.0 (`res/values-de`, `res/values-pt-rBR`, `res/xml/locales_config.xml`).
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ✅ Posture B — our own task store
@@ -206,10 +210,13 @@ most of it broken, absent or unusable. Reasoning in
`StorageMode.LOCAL` is gone (a stored `LOCAL` reads as `OWN`); `ProviderResolver`
narrows to discovering external providers; `ProviderChangeReceiver` filters on
the two external authorities only. `provider/PROVENANCE.md` is replaced by a
postscript in `STORAGE-DECISION.md`. **Breaking:** the
`de.jeanlucmakiola.agendula.tasks` authority and both custom permissions no
longer exist — anyone who pointed DAVx5 at that authority loses it, and the
release notes have to say so.
postscript in `STORAGE-DECISION.md`. **Not a breaking change for anyone, as it
turns out:** the `de.jeanlucmakiola.agendula.tasks` authority and its two custom
permissions were added and deleted inside this same unreleased cycle
(`git tag --contains` on the commit that added `:provider` comes back empty), so
no published version ever carried them and nobody could have pointed DAVx5 at
one. The release notes must *not* warn about losing something that never
shipped.
- ✅ Phase 6 — harden: `MigrationTestHelper` wired against the committed v1
schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test
covering the WAL case in both directions, and a performance check at 5,000
@@ -229,13 +236,65 @@ most of it broken, absent or unusable. Reasoning in
stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override
the way `updateInstance` does — phase 2 always specified this, only the edit
half had it.
- **Run the instrumented suite on a device.** Six classes — the Room seam, the
DAOs, the import, the migration harness, the restore path and the performance
check — all compile and none has ever executed. Everything load-bearing about
this migration is verified only by tests that have not run.
- ⬜ Verify on a device: a fresh install on the Room store, and an upgrade from a
v0.3.2 APK with seeded data landing every task, list and reminder.
- ⬜ Per-locale release notes for the dropped authority and permissions.
- **Ran the instrumented suite on a device** — 52 tests, 0 failures, Pixel 10
/ API 36, 13 Aug 2026; all six classes (the Room seam, the DAOs, the import, the
migration harness, the restore path, the performance check). Mind the ARM64
`aapt` trap: the task can exit non-zero on a fully green run, so read
`app/build/outputs/androidTest-results/connected/debug/*.xml` before believing
the exit code.
- **Re-ran it against the branch tip** — 66 tests, 0 failures, Pixel 10 /
API 37, 22 Sep 2026, now including `ExternalImportTest`. Worth having done:
the first run failed one test.
`RoomTasksDataSourceTest.alarmsRoundTripAndReplaceRatherThanAccumulate`
asserted `alarms()[id] == 30`, from before the seam returned a `TaskReminder`
rather than a bare minute count — the production value was right
(`TaskReminder(minutesBefore=30, fromStart=false)`) and the assertion was
stale. It compiled because Truth's `isEqualTo` takes `Any?`, so nothing but
executing it could have caught it. Exactly the defect class this item
existed to find.
- ⬜ Verify on a device — and note that the upgrade path this phase was designed
around is **not** the one real users are on. `OneShotImport` reads a bundled
dmfs provider's `databases/tasks.db`, and no release ever bundled one, so every
existing install's tasks sit in OpenTasks or tasks.org instead. What has to be
checked is therefore:
1. a fresh install landing on the Room store with nothing installed;
2. an upgrade from the 0.4.0 APK **with OpenTasks installed and permitted**
`autoMode()` must keep them on External and show their tasks unchanged;
3. Settings → Storage → *Copy tasks from …* moving those tasks into the Room
store, then the switch to *On this device* showing them, reminders included;
4. `OneShotImport` itself, which on a real device is only reachable by
side-loading a dev build of this branch first.
- ⬜ Release notes for 1.0.0. Nothing to say about the dropped authority or
permissions (see Phase 5); what needs saying is the own store, the copy path out
of an external provider, iCalendar export and list management.
### ✅ A way onto the new store for the people already using Agendula
The migration this branch was planned around turned out to serve nobody: the
vendored provider it reads from never shipped, so no install has the file
`OneShotImport` looks for. Everyone on 0.4.0 keeps their tasks in OpenTasks or
tasks.org, which `autoMode()` correctly keeps them on — and until now the only
route to the new store was to retype everything.
-`ExternalImport` (`data/tasks/transfer/`) copies the external provider's
lists, tasks and alarms into Room in one transaction with verified counts, the
same discipline as the legacy import. Task `uid`s survive, so these rows can be
attached to a CalDAV collection once sync lands instead of duplicating.
- ✅ Offered as **Settings → Storage → Copy tasks from …**, behind a confirm that
names the real number of tasks, and only while a provider is installed,
permitted, and no copy has succeeded yet — a second run would leave two of
everything, since what it writes is indistinguishable from hand-typed tasks
afterwards.
- ✅ One-directional by design. Writing a *list* into a third-party provider
means impersonating its sync adapter, and the external store is already the one
that can sync. What does not come across is documented on the class:
per-occurrence overrides, `EXDATE`, `CLASS`, `DURATION`, the per-task timezone,
and a task's exact `PRIORITY` digit.
- ✅ Switching stores at all now asks first. Neither store hands its rows to the
other, so the app looks emptied to anyone who expected a move.
- ✅ A failed `OneShotImport` is no longer silent. It used to return an
`ImportResult.Failed` that every caller dropped, leaving an upgrading user an
empty app and no explanation; it is now recorded, logged, and surfaced in
Settings → Storage as a retry — which is also what finally makes
`reimportFromArchive()` reachable from the app.
### ✅ Managing lists in the app
Owning the store made this mandatory: there is no longer a provider app to
+3 -1
View File
@@ -8,7 +8,9 @@
> [`OWN-STORE.md`](OWN-STORE.md) for what replaces it. The permissions,
> distribution, storage-mode and dead-end sections below remain accurate; treat
> the "our own provider" sections as the historical record of a decision that was
> made, shipped, and then costed properly.
> made, built, and then costed properly. It never reached a release: the
> `:provider` module was added and deleted inside this same unreleased cycle, so
> no published version of Agendula ever carried an authority of its own.
> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B =
> bundle OpenTasks" working notes, which are withdrawn (see
@@ -0,0 +1,13 @@
### Added
- Agendula keeps your tasks itself now — nothing else to install. Manage lists
in the app, repeat tasks, and export any list as iCalendar.
- Settings → Storage picks where tasks live, and offers to copy them over from
OpenTasks or tasks.org.
### Changed
- Another task app is now optional. Already use one? Nothing changes on update.
### Fixed
- Edited due times no longer revert, all-day tasks no longer drift a day, and
per-task reminders are saved.
@@ -1,10 +1,21 @@
Agendula is a modern, open-source task app for Android. It works directly on an
existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to
your device via DAVx5, SmoothSync or DecSync show up automatically, and changes
you make sync back the same way — no own account, no own sync.
Agendula is a modern, open-source task app for Android. It keeps your tasks in
its own storage on your device — nothing else to install, no account, nothing to
grant.
Prefer to keep them where they already are? Point Agendula at a tasks provider
you already use (OpenTasks or tasks.org) and it reads and writes that instead, so
any CalDAV tasks synced to your device via DAVx5, SmoothSync or DecSync show up
automatically and your changes sync back the same way. Switch between the two in
Settings → Storage, and copy your tasks across when you do.
What it does: lists you manage in the app, due dates and start dates, all-day
tasks, subtasks, priorities, progress, repeating tasks, and reminders that fire
at the exact due time. Export any list as a standard iCalendar .ics file through
Android's own file picker.
The differentiator is the design: real Material 3 Expressive throughout, with
dynamic color, expressive motion, and expressive shapes. Sibling to Calendula.
Privacy: zero telemetry, no analytics, no network access of its own — your data
never leaves the device except through the sync app you already trust.
Privacy: zero telemetry, no analytics, and no network access of its own — the app
holds no internet permission at all. Your data leaves the device only if you
export it, or through a sync app you already trust.
+33 -39
View File
@@ -1,25 +1,25 @@
[versions]
agp = "9.2.1"
kotlin = "2.3.21"
ksp = "2.3.9"
hilt = "2.59.2"
ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.10.0"
lifecycleRuntime = "2.11.0"
activityCompose = "1.13.0"
composeBom = "2026.05.01"
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
# Pin explicitly to override the BOM (which ships stable 1.4.0).
# Re-evaluate when 1.5.0 stable lands.
material3 = "1.5.0-alpha21"
material3 = "1.5.0-alpha26"
datastore = "1.2.1"
# Room — Agendula's own task store (docs/OWN-STORE.md).
room = "2.8.4"
kotlinxSerialization = "1.8.1"
# SAF directory writing for export/backup (DocumentFile).
documentfile = "1.1.0"
junit = "6.1.0"
junitPlatform = "6.1.0"
junit = "6.1.3"
junitPlatform = "6.1.3"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
@@ -29,16 +29,14 @@ turbine = "1.2.0"
# androidx.hilt — one version for the whole group. hilt-work and
# hilt-navigation-compose splitting versions inside a group is the kind of thing
# that resolves fine and then fails at runtime.
androidxHilt = "1.3.0"
androidxHilt = "1.4.0"
# WorkManager: sync runs here, triggered *through* the sync-adapter framework.
work = "2.11.2"
# Custom Tabs, for Nextcloud Login Flow v2.
browser = "1.10.0"
navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0"
lifecycleCompose = "2.11.0"
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"
# --- :dav (vendored dav4jvm) -------------------------------------------------
# dav4jvm 2.2.1 is the last OkHttp release; 3.0.0 deleted the OkHttp package for
@@ -56,22 +54,15 @@ xpp3 = "1.1.6"
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
# uses. BSD-3 — it goes on the attribution screen with the rest.
dnsjava = "3.6.3"
# --- :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"
# The vendored dav4jvm tests and :caldav's are JUnit 4, unlike the app's JUnit 5.
junit4 = "4.13.2"
hamcrest = "3.0"
mockito = "5.20.0"
# RFC 5545 recurrence expansion. The last of the dmfs dependencies: the vendored
# `:provider` module that needed the rest was deleted (docs/STORAGE-DECISION.md),
# and lib-recur stayed because our own store expands a series in process.
# Pinned at 0.12.2 — 0.16.0 removed RecurrenceSet. rfc5545-datetime arrives with
# it as part of its API surface, so it is not declared separately.
dmfsLibRecur = "0.12.2"
[libraries]
# AndroidX core
@@ -131,8 +122,11 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
# Test - Flow assertions
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
# Hilt navigation-compose (for hiltViewModel() in Composables)
# Hilt navigation-compose (hiltViewModel() moved out of it in 1.4.0, into
# hilt-lifecycle-viewmodel-compose — which it still brings in transitively, but
# we call that API directly, so it is declared here rather than inherited)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" }
androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
@@ -146,10 +140,6 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif
# ProcessLifecycleOwner — the WAL checkpoint hangs off ON_STOP.
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntime" }
# Glance — Jetpack home-screen widgets (Compose-like RemoteViews)
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
# Android tests - GrantPermissionRule
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
@@ -162,16 +152,20 @@ xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
# :caldav — RFC 6764 service discovery
dnsjava = { group = "dnsjava", name = "dnsjava", version.ref = "dnsjava" }
# :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" }
# Recurrence expansion for our own store
dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" }
# floret-kit — the shared house library, an included build (see settings.gradle.kts).
# Deliberately version-less: the composite build substitutes these coordinates
# with the submodule's own projects, so a version here would be fiction.
floret-core-time = { group = "de.jeanlucmakiola.floret", name = "core-time" }
floret-core-reminders = { group = "de.jeanlucmakiola.floret", name = "core-reminders" }
floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale" }
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+21 -2
View File
@@ -20,6 +20,10 @@ MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
# F-Droid's in-client changelog box truncates past roughly this length; CI reads
# this value rather than hardcoding its own copy.
MAX_CHARS=${MAX_CHARS:-500}
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
@@ -36,6 +40,21 @@ fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
# Hard limit, not a note. F-Droid truncates the "What's New" box in-client, so
# anything past this is written for nobody: the reader sees a sentence cut
# mid-word and no way to expand it. Keep the section in CHANGELOG.md short
# enough to survive that, and put the detail in the commit messages and docs,
# where there is room for it.
#
# CI enforces the same bound (.forgejo/workflows/ci.yaml) so a release cannot
# reach main with a changelog its users cannot read.
if [ "$CHARS" -gt "$MAX_CHARS" ]; then
cat >&2 <<MSG
ERROR: $OUT is $CHARS characters, over the $MAX_CHARS-character limit.
F-Droid truncates the in-client changelog, so this one would be cut off
mid-sentence. Shorten the "## [$VERSION]" section in CHANGELOG.md and re-run.
MSG
exit 1
fi
+20 -5
View File
@@ -38,11 +38,26 @@ adb shell pm revoke "$PKG" org.tasks.permission.WRITE_TASKS 2>/dev/null || t
adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
echo
echo "Installed and reset. Now verify ON THE DEVICE before releasing:"
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
echo " 2. Grant tasks access — the task list must load."
echo " 3. Create a task with a due reminder and confirm the notification fires."
echo " 4. Exercise the release's headline changes end to end."
echo "Installed and reset. Now verify ON THE DEVICE before releasing."
echo
echo "Which path you are on depends on what else is installed — since 1.0.0 the"
echo "app owns its store, so the default path grants nothing at all:"
echo
echo " No OpenTasks / tasks.org on the device (the default, and what a fresh"
echo " install looks like):"
echo " 1. Launch from a clean state — the task list must load straight away."
echo " There is no permission to grant in this mode, so no gate appears."
echo " 2. Create a list, then a task in it — a fresh install has neither."
echo " 3. Give the task a due reminder and confirm the notification fires."
echo
echo " With OpenTasks or tasks.org installed:"
echo " 1. Launch from a clean state — the permission gate must appear (no crash)."
echo " 2. Grant tasks access — that app's task list must load."
echo " 3. Settings -> Storage -> Copy tasks from ... — the confirm must name a"
echo " real count, and switching to \"On this device\" must then show them."
echo " 4. Create a task with a due reminder and confirm the notification fires."
echo
echo " Either way: exercise the release's headline changes end to end."
echo
echo "Watch for crashes with: adb logcat -b crash"
echo "Only merge the release branch to main once all of the above pass on a device"