Release 1.1.0 (#51)
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m32s

Agendula 1.1.0. Merging this triggers the release pipeline (F-Droid, Codeberg, Play) and mints the v1.1.0 tag.

- Tags, synced as CATEGORIES (#35)
- Saved filters, also usable in the Tasks widget (#47)
- Offline flavor without sync or network, published to Codeberg only (#39)
- New Today, Up next, Lists and Week widgets; widgets can show several lists
- Synced lists can be renamed and deleted
- All-day tasks no longer land a day off west of UTC in smart lists and filters

versionName 1.1.0 / versionCode 1010099. CHANGELOG has the 1.1.0 section, and What's New is written for en-US, en-GB, de-DE, pt-BR, plus new fr-FR and ar.

After the tag lands, fdroiddata!49998 needs a 1.1.0 build entry with gradle: full.

Closes #35, closes #39, closes #47

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/51
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-10-10 15:06:07 +02:00
1 parent b6f0391cb3
commit dd08c035df
219 files changed
+7010 -1227

No files matched your search

Binary file not shown.
@@ -1,123 +0,0 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestName
import org.junit.runner.RunWith
/**
* The credential store, against a real Keystore.
*
* Instrumented rather than Robolectric because the thing under test *is* the
* platform: a shadowed Keystore would encrypt and decrypt happily and prove
* nothing about whether the key spec is usable for background sync.
*
* The case that matters most is the last one. A restored backup carries the
* ciphertext but not the key — Keystore keys are non-exportable — so the blob
* becomes permanently undecryptable. That must surface as "sign in again", never
* as a crash and never as a silently non-syncing account, which is why
* `backup_rules.xml` excludes this file in the first place.
*/
@RunWith(AndroidJUnit4::class)
class CredentialStoreTest {
@get:Rule val testName = TestName()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<Preferences>
private lateinit var store: CredentialStore
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
// ⚠️ A file per test, and a scope we can cancel. DataStore's FileStorage
// keeps a process-wide set of active files and refuses a second
// connection to one ("There are multiple DataStores active for the same
// file"); the entry is released only when the owning scope's job
// completes, and the factory's default scope is never cancelled. Sharing
// one file across methods therefore fails every test after the first.
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
dataStore = PreferenceDataStoreFactory.create(scope = scope) {
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}")
}
store = CredentialStore(dataStore)
}
@After
fun tearDown() {
runTest { store.clearAll() }
scope.cancel()
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}").delete()
}
@Test
fun anAppPasswordRoundTrips() = runTest {
assertThat(store.put(accountId = 1L, appPassword = "s3cret-app-pw")).isTrue()
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("s3cret-app-pw"))
}
@Test
fun aNonLatin1PasswordSurvives() = runTest {
// The same charset trap the Basic interceptor has: anything that silently
// mangles "ä" produces a 401 the user reads as a wrong password.
store.put(accountId = 1L, appPassword = "pä§§wörd-🔐")
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("pä§§wörd-🔐"))
}
@Test
fun accountsDoNotShareACredential() = runTest {
store.put(1L, "first")
store.put(2L, "second")
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("first"))
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
}
@Test
fun anUnknownAccountIsAbsentRatherThanAnError() = runTest {
assertThat(store.get(99L)).isEqualTo(CredentialStore.Secret.Absent)
}
@Test
fun clearingRemovesOnlyThatAccount() = runTest {
store.put(1L, "first")
store.put(2L, "second")
store.clear(1L)
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Absent)
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
}
@Test
fun aCiphertextThisDeviceCannotDecryptMeansReAuthenticate() = runTest {
// Stands in for the restored-backup case: the blob is present and
// well-formed Base64, but was not produced by this device's key.
dataStore.edit {
it[stringPreferencesKey("caldav_app_password_1")] =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
}
@Test
fun aBlobThatIsNotBase64AtAllIsAlsoRecoverable() = runTest {
dataStore.edit { it[stringPreferencesKey("caldav_app_password_1")] = "not base64 !!" }
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
}
}
@@ -1,44 +0,0 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.R
import org.junit.Test
import org.junit.runner.RunWith
/**
* The account type and authority exist in two places that cannot see each other:
* `SyncContract`, derived from `BuildConfig.APPLICATION_ID`, and the `resValue`
* strings the XML descriptors read. Drift between them is invisible at build
* time and shows up as an account the sync framework will not trigger — the
* silent no-op, with nothing in the log.
*/
@RunWith(AndroidJUnit4::class)
class SyncContractTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Test
fun theAccountTypeMatchesTheAuthenticatorDescriptor() {
assertThat(SyncContract.ACCOUNT_TYPE)
.isEqualTo(context.getString(R.string.account_type))
}
@Test
fun theAuthorityMatchesTheSyncAdapterDescriptor() {
assertThat(SyncContract.AUTHORITY)
.isEqualTo(context.getString(R.string.sync_authority))
}
@Test
fun theAuthorityMatchesTheStubProviderInTheManifest() {
// The provider is what makes the authority real; a mismatch here means
// requestSync addresses nothing.
val provider = context.packageManager
.resolveContentProvider(SyncContract.AUTHORITY, 0)
assertThat(provider).isNotNull()
assertThat(provider!!.name).isEqualTo(SyncStubProvider::class.java.name)
}
}
@@ -212,6 +212,15 @@ class OneShotImportTest {
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
}
@Test
fun importsCategoryPropertiesAsTags() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
}
// --- running it -----------------------------------------------------------
@Test
@@ -8,27 +8,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The migration harness, proven against the committed schema in `app/schemas/`.
*
* There is one schema version today, so all there is to assert is that the helper
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
* export, the assets wiring and the identity hash all line up. That is the point:
* the first real migration only has to add its own case.
*
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
* database, and add a test here shaped like this:
*
* ```
* helper.createDatabase(TEST_DB, 1).use { db ->
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
* }
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
* // read the seeded rows back — validation proves the shape, not the data
* }
* ```
*/
/** Migrations, proven against the committed schemas in `app/schemas/`. */
@RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest {
@@ -62,6 +42,39 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() {
helper.createDatabase(TEST_DB, 1).use { db ->
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)")
db.execSQL(
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
arrayOf<Any?>(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"),
)
db.execSQL(
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
arrayOf<Any?>(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"),
)
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')")
}
helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db ->
db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c ->
c.moveToNext()
assertThat(c.getString(0)).isEqualTo("Work,Home")
assertThat(c.getString(1)).doesNotContain("CATEGORIES")
assertThat(c.getString(1)).contains("X-FOO:bar")
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getString(0)).isEqualTo("a,b")
assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n")
c.moveToNext()
assertThat(c.getString(0)).isEmpty()
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -107,6 +107,17 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue()
}
@Test
fun copiesTagsCleanedUp() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
importer.run()
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
.containsExactly("Shop", "Home").inOrder()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
@@ -241,6 +252,7 @@ class ExternalImportTest {
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask(
taskId = id,
uid = uid,
@@ -260,6 +272,7 @@ class ExternalImportTest {
rrule = null,
rdate = null,
parentId = parentId,
tags = tags,
)
private companion object {