Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj ad8472c65f Reminders follow completions made outside the notification (#127, #129) (#130)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 13m29s
In the app's own store, nothing re-ran the reminder scheduler when a task was completed in the list, the detail screen or a widget, or when a sync pulled in a completion from another device. The alarm stayed armed until the next app start or the daily maintenance pass. A reminder that was already showing never came down, because only its own "Done" button cleared it.

There was a second cause for repeating tasks. A ticked occurrence is saved as a row with its own id, but `occurrence()` searched the list by the series id. It never found the ticked occurrence, fell back to the next open one and posted the reminder anyway. That's probably the first half of #127.

- `ReminderRefresher` runs one debounced `ReminderScheduler.sync()` per burst of changes to tasks, lists or alarms. Account bookkeeping doesn't trigger it. The refresher and `TaskWidgetUpdater` now share a `StoreChangeDebouncer`.
- Each scheduler pass withdraws posted reminders whose occurrence is closed. It also runs when reminders are switched off. It only looks at reminders from the current storage mode.
  - A missing task counts as deleted only in Agendula's own store. A provider that fails a read returns nothing rather than throwing.
- Reminders now record their occurrence in the notification extras. Reminders posted by the previous version are only withdrawn for tasks that don't repeat.
- `occurrence()` looks up occurrences by their start time, so it finds ticked rows. It falls back to scanning the list for providers whose exception rows the direct lookup misses.
- A fired reminder now stays marked as fired for the 6 h grace period, even after its task is ticked. Ticking a task and then tapping Undo no longer makes the reminder ring again.
- After posting, the receivers check the task once more. That catches a completion that lands between the check and the post.
- These callers no longer run the scheduler themselves, because the refresher now covers them: the edit screen, the Done action, a storage-mode switch, and the two import paths in Settings. `ProviderWriter` asks the refresher for a pass instead of syncing directly.
  - `SyncWorker` still syncs directly. Once the worker returns, nothing keeps the process alive for the debounce.

Closes #127
Closes #129

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/130
2026-11-02 18:54:13 +01:00
makiolaj c2320de60b Prepare 1.3.0-beta.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 10m54s
2026-10-28 17:57:59 +01:00
makiolaj 62ee105f9d App name picker shows launcher previews like Calendula (#104) 2026-10-28 17:57:59 +01:00
makiolaj b1abcecd06 Prepare 1.3.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 11m19s
2026-10-28 14:50:31 +01:00
makiolaj ba48a9821c Fix review findings on server trust (#68)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Renewed pins can be re-trusted via sign in again, discovery keeps walking
past an untrusted SRV target, trust is bound to the address it was given
for, reauth replaces old pins, and the trust save is covered by rollback.
2026-10-28 14:49:49 +01:00
Jean-Luc Makiola fd8e4c9450 Self-signed certificates and plain http for self-hosted servers (#68) (#116)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:32:17 +01:00
40 changed files with 818 additions and 215 deletions

No files matched your search

+23
View File
@@ -7,6 +7,29 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Added
- Self-hosted servers with a self-signed certificate can be added: Agendula
shows the certificate's fingerprint and asks whether to trust it. A server
reached over plain http can be added too, after a warning. If a trusted
certificate is later renewed, "Sign in again" on the account asks about the
new one (#68).
- An account can be given its own name (#105).
- Pull down on the task list to sync (#73).
- Task list sections can be collapsed (#91).
- A pure black theme for AMOLED screens (#99).
- Agendula can show up as "Tasks" in your launcher, picked from launcher
previews as in Calendula (#104).
- Reminder notifications show the task's description (#94).
### Changed
- Adding or opening a task from a widget returns you to the home screen
afterwards (#93).
- A swipe that starts at the screen edge no longer swipes the task (#90).
### Fixed
- Completing a task from the list, a widget or another device now cancels its
upcoming reminder and clears one already showing (#127, #129).
## [1.2.0] - 2026-10-26
### Added
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1020099
versionName = "1.2.0"
versionCode = 1030002
versionName = "1.3.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -193,7 +194,15 @@ class AccountRepository @Inject constructor(
inserted += attach(id, selected)
id
}
serverTrust.put(accountId, trust)
// Inside the rollback's reach: a DataStore failure here would otherwise
// leave the row with no credential, the hazard the comment above names.
if (!saveTrust(accountId, trust)) {
rollback(accountId, inserted)
return@withContext Outcome.CredentialFailed(
Outcome.Cause.NOT_SAVED,
"the server trust could not be stored",
)
}
if (!credentials.put(accountId, appPassword)) {
// Never leave a half-made account behind: without a credential it
@@ -323,7 +332,8 @@ class AccountRepository @Inject constructor(
-> Collections.NeedsSignIn
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
// A certificate that changed since it was trusted; signing in again re-asks.
// A certificate that changed since it was trusted. The account offers
// "Sign in again" for a certificate failure, and that flow re-asks.
is CalDavDiscovery.Outcome.UntrustedCertificate ->
Collections.Failed(CalDavDiscovery.Outcome.Cause.TLS)
}
@@ -415,6 +425,11 @@ class AccountRepository @Inject constructor(
"the device keystore would not store the password",
)
}
// Before anything else changes, so a failure leaves the account stopped
// and the sign-in retryable rather than signed in with the old trust.
if (!withContext(NonCancellable) { saveTrust(accountId, trust) }) {
return Outcome.CredentialFailed(Outcome.Cause.NOT_SAVED, "the server trust could not be stored")
}
withContext(NonCancellable) {
database.runInTransaction {
database.accounts().update(
@@ -429,7 +444,6 @@ class AccountRepository @Inject constructor(
)
attach(accountId, selected)
}
serverTrust.put(accountId, trust)
// ⚠️ Ticked lists are attached; unticked ones are left alone. The
// picker pre-ticks everything *writable*, not everything already
// attached, so detaching what is unticked would silently stop
@@ -464,9 +478,18 @@ class AccountRepository @Inject constructor(
inserted.forEach { database.taskLists().delete(it) }
database.accounts().delete(accountId)
}
serverTrust.forget(accountId)
// Ids are never reused, and a later create overwrites the keys anyway.
runCatching { serverTrust.forget(accountId) }
}
private suspend fun saveTrust(accountId: Long, trust: ServerTrust): Boolean =
try {
serverTrust.put(accountId, trust)
true
} catch (_: IOException) {
false
}
/**
* Puts every existing account back on the periodic schedule.
*
@@ -2,11 +2,9 @@ package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import de.jeanlucmakiola.caldav.ServerTrust
@@ -67,8 +65,7 @@ class PendingLoginFlowStore @Inject constructor(
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
prefs[POLL_TOKEN] = flow.pollToken
prefs[DEADLINE] = flow.deadlineEpochSeconds
prefs[TRUSTED_CERTIFICATES] = trust.pinnedCertificates
prefs[CLEARTEXT] = trust.allowCleartext
TRUST.write(prefs, trust)
}
}
@@ -79,8 +76,7 @@ class PendingLoginFlowStore @Inject constructor(
prefs.remove(POLL_ENDPOINT)
prefs.remove(POLL_TOKEN)
prefs.remove(DEADLINE)
prefs.remove(TRUSTED_CERTIFICATES)
prefs.remove(CLEARTEXT)
TRUST.remove(prefs)
}
}
@@ -145,20 +141,13 @@ class PendingLoginFlowStore @Inject constructor(
)
}
private suspend fun trust(): ServerTrust {
val prefs = dataStore.data.first()
return ServerTrust(
pinnedCertificates = prefs[TRUSTED_CERTIFICATES].orEmpty(),
allowCleartext = prefs[CLEARTEXT] ?: false,
)
}
private suspend fun trust(): ServerTrust = TRUST.read(dataStore.data.first())
private companion object {
val LOGIN_URL = stringPreferencesKey("login_flow_url")
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
val DEADLINE = longPreferencesKey("login_flow_deadline")
val TRUSTED_CERTIFICATES = stringSetPreferencesKey("login_flow_trusted_certificates")
val CLEARTEXT = booleanPreferencesKey("login_flow_cleartext")
val TRUST = ServerTrustKeys("login_flow_trusted_certificates", "login_flow_cleartext")
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.lifecycle.SavedStateHandle
import de.jeanlucmakiola.caldav.ServerTrust
/**
* The one mapping between a [ServerTrust] and the key-value stores it is kept
* in, so a field added to it cannot be dropped by one of them.
*/
class ServerTrustKeys(private val certificatesName: String, private val cleartextName: String) {
private val certificates = stringSetPreferencesKey(certificatesName)
private val cleartext = booleanPreferencesKey(cleartextName)
fun read(prefs: Preferences) = ServerTrust(
pinnedCertificates = prefs[certificates].orEmpty(),
allowCleartext = prefs[cleartext] ?: false,
)
fun write(prefs: MutablePreferences, trust: ServerTrust) {
if (trust.pinnedCertificates.isEmpty()) {
prefs.remove(certificates)
} else {
prefs[certificates] = trust.pinnedCertificates
}
if (trust.allowCleartext) prefs[cleartext] = true else prefs.remove(cleartext)
}
fun remove(prefs: MutablePreferences) = write(prefs, ServerTrust.DEFAULT)
fun read(handle: SavedStateHandle) = ServerTrust(
pinnedCertificates = handle.get<ArrayList<String>>(certificatesName).orEmpty().toSet(),
allowCleartext = handle[cleartextName] ?: false,
)
fun write(handle: SavedStateHandle, trust: ServerTrust) {
handle[certificatesName] = ArrayList(trust.pinnedCertificates)
handle[cleartextName] = trust.allowCleartext
}
}
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.ServerTrust
import kotlinx.coroutines.flow.Flow
@@ -26,29 +24,15 @@ class ServerTrustStore @Inject constructor(
dataStore.data.map { read(it, accountId) }.distinctUntilChanged()
suspend fun put(accountId: Long, trust: ServerTrust) {
dataStore.edit { prefs ->
if (trust.pinnedCertificates.isEmpty()) {
prefs.remove(certificates(accountId))
} else {
prefs[certificates(accountId)] = trust.pinnedCertificates
}
if (trust.allowCleartext) prefs[cleartext(accountId)] = true else prefs.remove(cleartext(accountId))
}
dataStore.edit { keys(accountId).write(it, trust) }
}
suspend fun forget(accountId: Long) {
dataStore.edit { prefs ->
prefs.remove(certificates(accountId))
prefs.remove(cleartext(accountId))
}
dataStore.edit { keys(accountId).remove(it) }
}
private fun read(prefs: Preferences, accountId: Long) = ServerTrust(
pinnedCertificates = prefs[certificates(accountId)].orEmpty(),
allowCleartext = prefs[cleartext(accountId)] ?: false,
)
private fun read(prefs: Preferences, accountId: Long) = keys(accountId).read(prefs)
private fun certificates(accountId: Long) = stringSetPreferencesKey("server_trust_certs_$accountId")
private fun cleartext(accountId: Long) = booleanPreferencesKey("server_trust_cleartext_$accountId")
private fun keys(accountId: Long) =
ServerTrustKeys("server_trust_certs_$accountId", "server_trust_cleartext_$accountId")
}
@@ -56,8 +56,8 @@ class SyncWorker @AssistedInject constructor(
// log line is the same as saying nothing. Only what is new: the
// store has already dropped whatever the user has been told.
noticeNotifier.post(accountName, outcome.notices)
// Nothing else re-arms reminders for what a sync pulled in: in our
// own store no provider broadcast fires.
// Here rather than left to ReminderRefresher's debounce: once
// the worker returns, nothing keeps the process alive for it.
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
runCatching { reminderScheduler.sync() }
}
@@ -169,7 +169,7 @@ internal fun AccountDetailScreen(
)
}
}
if (shown.needsSignIn) {
if (shown.offersSignIn) {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sign_in_again),
@@ -178,7 +178,8 @@ internal fun AccountDetailScreen(
onClick = { onSignInAgain(account) },
)
}
} else {
}
if (!shown.needsSignIn) {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sync_now),
@@ -119,7 +119,7 @@ internal fun AccountsScreen(
when {
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
!usable -> Unit
row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
row.offersSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
Icon(
Icons.AutoMirrored.Rounded.Login,
contentDescription = stringResource(R.string.accounts_sign_in_again),
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
import de.jeanlucmakiola.agendula.data.sync.SyncFailure
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
@@ -191,7 +192,18 @@ class AccountsViewModel @Inject constructor(
*/
val notices: List<SyncNotice> = emptyList(),
val lists: ListPush = ListPush.NONE,
)
) {
/**
* The last sync was refused over the server's certificate — typically a
* pinned one that was renewed. Nothing sets [needsSignIn] for that, yet
* signing in again is the only route that can trust the new one.
*/
val certificateRefused: Boolean
get() = account.lastSyncError?.let { SyncFailure.of(it).kind == SyncFailure.Kind.CERTIFICATE } == true
/** Whether "Sign in again" is the way forward for this account. */
val offersSignIn: Boolean get() = needsSignIn || certificateRefused
}
/**
* Accounts whose removal has started and not finished.
@@ -440,11 +440,19 @@ private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAcco
) { Text(stringResource(R.string.add_account_change_address)) }
}
is AddAccountStep.TrustCertificate -> PrimaryAction(
label = stringResource(R.string.add_account_certificate_trust),
enabled = true,
onClick = viewModel::onCertificateTrusted,
)
// The safe answer stays visible, as it does for plain http: back is
// not something every user knows to look for on a security prompt.
is AddAccountStep.TrustCertificate -> {
PrimaryAction(
label = stringResource(R.string.add_account_certificate_trust),
enabled = true,
onClick = viewModel::onCertificateTrusted,
)
TextButton(
onClick = { viewModel.onBackWithin() },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_certificate_decline)) }
}
is AddAccountStep.WaitingForBrowser -> {
// A failed flow is retried by default. With no browser a retry
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.ServerTrustKeys
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavProvider
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
@@ -17,6 +18,8 @@ import de.jeanlucmakiola.caldav.ServerTrust
import de.jeanlucmakiola.caldav.ServiceDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
@@ -337,26 +340,33 @@ class AddAccountViewModel @Inject constructor(
/** What the user agreed to trust in this flow, handed to every gateway call. */
private var trust: ServerTrust
get() = ServerTrust(
pinnedCertificates = handle.get<ArrayList<String>>(KEY_TRUSTED_CERTIFICATES).orEmpty().toSet(),
allowCleartext = handle[KEY_CLEARTEXT] ?: false,
)
set(value) {
handle[KEY_TRUSTED_CERTIFICATES] = ArrayList(value.pinnedCertificates)
handle[KEY_CLEARTEXT] = value.allowCleartext
}
get() = TRUST_KEYS.read(handle)
set(value) = TRUST_KEYS.write(handle, value)
/** The address plain http was accepted for; another address asks again. */
private var cleartextAddress: String?
get() = handle[KEY_CLEARTEXT_ADDRESS]
set(value) { handle[KEY_CLEARTEXT_ADDRESS] = value }
/**
* The address [trust] was given for. Pins and plain http both belong to it,
* so submitting any other address starts from nothing.
*/
private var trustAddress: String?
get() = handle[KEY_TRUST_ADDRESS]
set(value) { handle[KEY_TRUST_ADDRESS] = value }
/**
* The pins in [trust] came from the account being signed in to, not from
* this flow. The first certificate trusted now replaces them, so a renewed
* certificate retires the one it renews instead of joining it.
*/
private var inheritedPins: Boolean
get() = handle[KEY_INHERITED_PINS] ?: false
set(value) { handle[KEY_INHERITED_PINS] = value }
/** Loading a re-authenticated account's stored trust, which discovery waits for. */
private var trustLoad: Job? = null
/** What [AddAccountStep.TrustCertificate] re-runs once trusted, and where Back goes. */
private var trustRetry: (() -> Unit)? = null
private var trustDeclined: AddAccountStep? = null
/** The open [AddAccountStep.TrustCertificate] question, and where declining it goes. */
private class TrustPrompt(val answer: CompletableDeferred<Unit>, val back: AddAccountStep)
private var trustPrompt: TrustPrompt? = null
/** Which hosts asked for credentials, for the cross-domain diagnostic. */
private var hostsNeedingAuth: List<String>
@@ -461,7 +471,8 @@ class AddAccountViewModel @Inject constructor(
trustLoad = viewModelScope.launch {
val stored = runCatching { repository.trustFor(accountId) }.getOrDefault(ServerTrust.DEFAULT)
trust = stored
if (stored.allowCleartext) cleartextAddress = address
trustAddress = address
inheritedPins = stored.pinnedCertificates.isNotEmpty()
}
}
@@ -521,9 +532,6 @@ class AddAccountViewModel @Inject constructor(
updateState { it.copy(originMismatch = null) }
typedInput = input
addressInput = input
trustRetry = null
trustDeclined = null
if (input != cleartextAddress) trust = trust.copy(allowCleartext = false)
val quirk = ServerQuirk.forInput(input)
if (quirk?.isFatal == true) {
@@ -547,8 +555,18 @@ class AddAccountViewModel @Inject constructor(
private fun discoverAddress() {
working(AddAccountMessage.Progress.Discovering)
viewModelScope.launch {
// ⚠️ After the stored trust has landed, not before: reset first and
// the load would put the old address's trust back on top.
trustLoad?.join()
when (val outcome = gateway.discover(typedInput, trust = trust)) {
if (typedInput != trustAddress) {
trust = ServerTrust.DEFAULT
inheritedPins = false
trustAddress = typedInput
}
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.Discovering) {
gateway.discover(typedInput, trust = trust)
} ?: return@launch
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
@@ -570,8 +588,8 @@ class AddAccountViewModel @Inject constructor(
backToAddressOrServer(outcome.cause)
}
is CalDavDiscovery.Outcome.UntrustedCertificate ->
askToTrust(outcome.certificate, back = addressStep(typedInput)) { discoverAddress() }
// Asked about inside discoverTrusting, which only returns it declined.
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
}
}
}
@@ -584,29 +602,49 @@ class AddAccountViewModel @Inject constructor(
/** The warning is read and plain http is wanted anyway. */
fun onCleartextConfirmed() {
if (current.step !is AddAccountStep.ConfirmCleartext) return
cleartextAddress = typedInput
trust = trust.copy(allowCleartext = true)
trustAddress = typedInput
discoverAddress()
}
/** The fingerprint is checked; pin it and re-run what it stopped. */
fun onCertificateTrusted() {
val step = current.step as? AddAccountStep.TrustCertificate ?: return
val retry = trustRetry ?: return
trust = trust.withCertificate(step.certificate.fingerprint)
trustRetry = null
trustDeclined = null
retry()
val prompt = trustPrompt ?: return
trustPrompt = null
val fingerprint = step.certificate.fingerprint
trust = if (inheritedPins) {
trust.copy(pinnedCertificates = setOf(fingerprint.lowercase()))
} else {
trust.withCertificate(fingerprint)
}
inheritedPins = false
prompt.answer.complete(Unit)
}
private fun askToTrust(certificate: ServerCertificate, back: AddAccountStep, retry: () -> Unit) {
// Already pinned and still refused: asking again could only loop.
if (certificate.fingerprint in trust.pinnedCertificates) {
return backToAddress(CalDavDiscovery.Outcome.Cause.TLS)
/**
* Runs [discover], and on a certificate the user could trust asks about it
* and runs it again once they have. Null when they declined; the step is
* then already [back].
*/
private suspend fun discoverTrusting(
back: AddAccountStep,
progress: AddAccountMessage.Progress,
discover: suspend () -> CalDavDiscovery.Outcome,
): CalDavDiscovery.Outcome? {
while (true) {
val outcome = discover()
if (outcome !is CalDavDiscovery.Outcome.UntrustedCertificate) return outcome
val prompt = TrustPrompt(CompletableDeferred(), back)
trustPrompt = prompt
updateState { it.copy(step = AddAccountStep.TrustCertificate(outcome.certificate), openInBrowser = null) }
try {
prompt.answer.await()
} catch (_: CancellationException) {
return null
}
working(progress)
}
trustRetry = retry
trustDeclined = back
updateState { it.copy(step = AddAccountStep.TrustCertificate(certificate), openInBrowser = null) }
}
/**
@@ -663,7 +701,11 @@ class AddAccountViewModel @Inject constructor(
val root = serverRoot
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
val credentials = CalDavGateway.Credentials(username, appPassword, root)
when (val outcome = gateway.discover(typedInput, credentials, trust)) {
val outcome = discoverTrusting(
AddAccountStep.EnterCredentials(username = username),
AddAccountMessage.Progress.SigningIn,
) { gateway.discover(typedInput, credentials, trust) } ?: return@launch
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
// A second rejection is the point at which naming the provider's
@@ -685,10 +727,7 @@ class AddAccountViewModel @Inject constructor(
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.UntrustedCertificate -> askToTrust(
outcome.certificate,
back = AddAccountStep.EnterCredentials(username = username),
) { discoverWithCredentials() }
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
}
}
}
@@ -872,15 +911,17 @@ class AddAccountViewModel @Inject constructor(
/** Discovery with a password the login flow just minted, at the server that issued it. */
private suspend fun discoverApproved(server: HttpUrl) {
working(AddAccountMessage.Progress.ReadingLists)
val outcome = gateway.discover(
// The server the credentials were *issued by*, not the
// host the user typed — the two differ in exactly the
// host-mismatch case this flow already models, and
// getting it wrong burns the one-shot app password.
target = server.toString(),
credentials = CalDavGateway.Credentials(username, appPassword, server),
trust = trust,
)
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.ReadingLists) {
gateway.discover(
// The server the credentials were *issued by*, not the
// host the user typed — the two differ in exactly the
// host-mismatch case this flow already models, and
// getting it wrong burns the one-shot app password.
target = server.toString(),
credentials = CalDavGateway.Credentials(username, appPassword, server),
trust = trust,
)
} ?: return
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
// ⚠️ Forward what actually happened. Reporting every
@@ -909,10 +950,7 @@ class AddAccountViewModel @Inject constructor(
CalDavDiscovery.Outcome.Unauthenticated ->
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
is CalDavDiscovery.Outcome.UntrustedCertificate ->
askToTrust(outcome.certificate, back = addressStep(typedInput)) {
viewModelScope.launch { discoverApproved(server) }
}
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
}
}
@@ -1112,10 +1150,10 @@ class AddAccountViewModel @Inject constructor(
}
is AddAccountStep.TrustCertificate -> {
val back = trustDeclined ?: addressStep(typedInput)
trustRetry = null
trustDeclined = null
updateState { it.copy(step = back, fatal = null) }
val prompt = trustPrompt
trustPrompt = null
prompt?.answer?.cancel()
updateState { it.copy(step = prompt?.back ?: addressStep(typedInput), fatal = null) }
true
}
@@ -1163,9 +1201,10 @@ class AddAccountViewModel @Inject constructor(
trustLoad?.cancel()
trustLoad = null
trust = ServerTrust.DEFAULT
cleartextAddress = null
trustRetry = null
trustDeclined = null
trustAddress = null
inheritedPins = false
trustPrompt?.answer?.cancel()
trustPrompt = null
current = AddAccountUiState()
}
@@ -1324,7 +1363,10 @@ class AddAccountViewModel @Inject constructor(
)
viewModelScope.launch {
val credentials = CalDavGateway.Credentials(username, appPassword, root)
when (val outcome = gateway.discover(target, credentials, trust)) {
val outcome = discoverTrusting(addressStep(addressInput), AddAccountMessage.Progress.ReadingLists) {
gateway.discover(target, credentials, trust)
} ?: return@launch
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
@@ -1335,8 +1377,7 @@ class AddAccountViewModel @Inject constructor(
credentialsRejected()
}
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
is CalDavDiscovery.Outcome.UntrustedCertificate ->
askToTrust(outcome.certificate, back = addressStep(addressInput)) { resumeDiscovery(hasSetup) }
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
}
}
}
@@ -1566,13 +1607,19 @@ class AddAccountViewModel @Inject constructor(
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
const val KEY_TRUSTED_CERTIFICATES = "add_account.trusted_certificates"
const val KEY_CLEARTEXT = "add_account.cleartext"
const val KEY_CLEARTEXT_ADDRESS = "add_account.cleartext_address"
const val KEY_TRUST_ADDRESS = "add_account.trust_address"
const val KEY_INHERITED_PINS = "add_account.inherited_pins"
val TRUST_KEYS = ServerTrustKeys(KEY_TRUSTED_CERTIFICATES, KEY_CLEARTEXT)
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
val NO_SERVER_FOUND = setOf(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
// The domain's own host would not set up TLS: usually its website,
// not a CalDAV server, so the server field is the way on.
CalDavDiscovery.Outcome.Cause.TLS,
)
/**
@@ -11,6 +11,9 @@
itself refuses an http:// URL for everyone else. This file cannot express a
per-account choice, only a per-host one, and the hosts are the user's.
⚠️ That makes CalDavHttp the only gate. A client built any other way would
speak http silently, so HttpClientConstructionTest fails the build on one.
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
VPN" app's certificate, one installed during some earlier debugging — can
@@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -65,15 +66,12 @@ class AgendulaApp : Application(), Configuration.Provider {
// v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
// A switch rebuilds the alarms through ReminderRefresher, which hears it
// as a store change. Channels only once startup's own null -> stored
// transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) {
scope.launch { runCatching { scheduler.sync() } }
runCatching { entryPoint.taskNotifier().refreshChannel() }
}
if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() }
}
startupGate.start()
refreshNotificationChannels()
@@ -88,6 +86,7 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.reminderRefresher().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
@@ -124,6 +123,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater
fun reminderRefresher(): ReminderRefresher
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have
@@ -44,8 +46,7 @@ class DueReminderReceiver : BroadcastReceiver() {
}
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@launch
val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task, settings)
runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) }
} finally {
pending.finish()
}
@@ -74,17 +75,51 @@ class DueReminderReceiver : BroadcastReceiver() {
/**
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
* task's current one when there is no anchor or it can no longer be found.
* [TasksDataSource.task] alone resolves a series to whichever occurrence is
* current, which is not necessarily the one a reminder was armed for.
*
* ⚠️ A ticked or edited occurrence is a row of its own with its own id, so
* matching list rows on [taskId] misses it; [TasksDataSource.task] with the
* anchor finds it by start. The list scan is for a provider whose exception
* rows that lookup does not reach.
*/
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
val current = task(taskId) ?: return null
if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
) {
return current
val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds)
val found = task(taskId, anchor) ?: return null
if (anchor == null || found.occurrenceStart == anchor) return found
return tasks(TaskQuery(listId = found.listId, includeCompleted = true))
.firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor }
?: found
}
/**
* Whether a reminder posted for [occurrenceStart] is done with, given [current]
* as [occurrence] resolved it.
*
* - No task: gone, but only when [missingMeansGone] — a provider that fails a
* read hands back nothing rather than throwing.
* - [occurrenceStart] null: posted before reminders recorded their occurrence,
* so only a task that does not recur can be judged.
* - A lookup that fell back to another occurrence says nothing about the
* reminded one, so it stays.
*/
internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean {
if (current == null) return missingMeansGone
val same = when (occurrenceStart) {
null -> current.occurrenceStart == null
ScheduledReminder.NO_OCCURRENCE -> true
else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
}
return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
.firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
?: current
return same && current.isClosed
}
/**
* Posts the reminder for [taskId]'s occurrence if it is still open, then checks
* once more: a completion landing between the read and the post may already
* have had its withdrawal pass, which ran before there was anything to withdraw.
*/
internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) {
val task = dataSource.occurrence(taskId, occurrenceStart) ?: return
if (task.isClosed) return
postDue(task, settings)
val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return }
if (now == null || now.isClosed) cancel(taskId)
}
@@ -32,7 +32,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
@Inject lateinit var reminderScheduler: ReminderScheduler
@Inject lateinit var repository: TasksRepository
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var settingsPrefs: SettingsPrefs
@@ -54,7 +53,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
?.let(Instant::fromEpochMilliseconds)
repository.setCompleted(taskId, anchor, completed = true)
reminderScheduler.sync()
}
ACTION_SNOOZE -> runCatching {
@@ -67,8 +65,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@runCatching
startupGate.awaitReady()
val task = dataSource.occurrence(taskId, occurrence)
if (task != null && !task.isClosed) notifier.postDue(task, settings)
notifier.postIfOpen(dataSource, taskId, occurrence, settings)
}
}
} finally {
@@ -21,7 +21,9 @@ internal data class ReminderDiff(
fired: Set<ScheduledReminder> = emptySet(),
isArmed: (ScheduledReminder) -> Boolean,
): ReminderDiff {
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
// A fired entry counts even once the store dropped it, so a task
// reopened within the grace does not ring again.
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired
return ReminderDiff(
cancel = live - desired,
arm = desired - live,
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps reminders in step with the store: any change to tasks, lists or alarms —
* a tick in the list or on a widget, a sync pulling in a completion from another
* device, an edit, a store switch — runs one debounced [ReminderScheduler.sync].
* Started once from `AgendulaApp`, after the startup gate, so it observes the
* right store.
*/
@Singleton
class ReminderRefresher @Inject constructor(
dataSource: TasksDataSource,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) {
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() }
fun start() {
changes.start()
}
/** Re-sync soon, folded into any pass a store change already asked for. */
fun requestRefresh() = changes.request()
private companion object {
const val DEBOUNCE_MILLIS = 1_000L
}
}
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
@@ -23,8 +24,9 @@ import javax.inject.Singleton
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
* on app start, on boot, on a store switch, and on an external provider change;
* it diffs against [ScheduledReminderStore] so only changed alarms move.
* on app start, on boot, on a store switch and on every change to the store (see
* [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed
* alarms move, and takes down posted reminders whose task is done with.
*/
@Singleton
class ReminderScheduler @Inject constructor(
@@ -34,6 +36,7 @@ class ReminderScheduler @Inject constructor(
private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver,
private val snoozeScheduler: ReminderSnoozeScheduler,
private val notifier: TaskNotifier,
@IoDispatcher private val io: CoroutineDispatcher,
) {
private val syncLock = Mutex()
@@ -65,19 +68,23 @@ class ReminderScheduler @Inject constructor(
// Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
val readable = providerResolver.canReadStore()
if (!settings.remindersEnabled || !readable) {
clearAll()
// Switched off, a reminder already up still goes when its task does.
if (readable) withdrawStale()
return
}
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return }
// Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
val now = System.currentTimeMillis()
val desired = ReminderPlanner.plan(
tasks = tasks,
perTask = perTask,
settings = settings,
now = System.currentTimeMillis(),
now = now,
zone = ZoneId.systemDefault(),
)
@@ -92,7 +99,10 @@ class ReminderScheduler @Inject constructor(
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
// Trusting it alone left every reminder after a reboot unarmed for good.
val fired = store.fired()
// ⚠️ Kept through the missed-reminder grace even once it drops out of
// the plan: a tick undone a moment later would otherwise re-arm a
// trigger in the past, and the reminder would ring a second time.
val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS }
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
diff.cancel.forEach { cancel(it) }
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
@@ -100,7 +110,25 @@ class ReminderScheduler @Inject constructor(
// — uncancellable, and firing for tasks that no longer exist — and the
// exception escaped into BootReceiver's goAsync().
val armed = diff.arm.filter { schedule(it) }
store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
store.replace(diff.keep + armed, fired = fired, exact = canExact)
withdrawStale()
}
/**
* A reminder already on screen outlives its alarm: completing the task in
* the list, on a widget or on another device has to take it down too.
*/
private fun withdrawStale() {
// Only our own store tells a deleted task from a failed read.
val missingMeansGone = providerResolver.mode() == StorageMode.OWN
runCatching {
notifier.withdrawStale { taskId, occurrence ->
runCatching {
val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE)
reminderIsStale(occurrence, current, missingMeansGone)
}.getOrDefault(false)
}
}
}
/** Whether the system still holds this alarm's PendingIntent. */
@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateFormat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -103,6 +104,11 @@ class TaskNotifier @Inject constructor(
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(tapIntent)
.addExtras(
Bundle().apply {
putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE)
},
)
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
.addAction(
0,
@@ -117,6 +123,24 @@ class TaskNotifier @Inject constructor(
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
}
/**
* Takes down every posted reminder [isStale] says is done with — completed,
* cancelled or deleted since it went off, here or on another device. The
* occurrence is null for a reminder posted before it was recorded.
*/
fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) {
// Only the current store's: another store's task ids name other tasks.
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true }
.forEach { posted ->
val taskId = posted.tag?.toLongOrNull() ?: return@forEach
val extras = posted.notification.extras
val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null
if (isStale(taskId, occurrence)) cancel(taskId)
}
}
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
context,
task.taskId.toInt(),
@@ -190,6 +214,7 @@ class TaskNotifier @Inject constructor(
private const val LEGACY_CHANNEL_ID = "task_reminders"
private const val LIST_CHANNEL_PREFIX = "reminders_"
private const val NOTIFICATION_ID = 1
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val CHANNEL_PREFS = "reminder_channels"
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
@@ -81,16 +81,22 @@ class ModeRoutingTasksDataSource(
* So the registration moves with the mode, and the switch itself counts as a
* change: the data underneath every live flow has just been replaced.
*/
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
override fun registerObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerObserver(onChange) }
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerTaskObserver(onChange) }
private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable {
val lock = Any()
var closed = false
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull()
val modeHandle = resolver.onModeChanged {
synchronized(lock) {
if (!closed) {
handle?.let { runCatching { it.close() } }
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
handle = runCatching { register(active()) }.getOrNull()
}
}
onChange()
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs [action] once per burst of store changes: [register] hooks the store's
* observer, and [request] asks for a pass without one. Started at most once.
*/
class StoreChangeDebouncer(
private val scope: CoroutineScope,
private val delayMillis: Long,
private val register: (onChange: () -> Unit) -> AutoCloseable,
private val action: suspend () -> Unit,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private val started = AtomicBoolean(false)
/** @return whether this call started it; false when it already was. */
@OptIn(FlowPreview::class)
fun start(): Boolean {
if (!started.compareAndSet(false, true)) return false
runCatching { register { requests.trySend(Unit) } }
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } }
}
return true
}
fun request() {
requests.trySend(Unit)
}
}
@@ -152,4 +152,7 @@ interface TasksDataSource {
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable
/** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */
fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange)
}
@@ -1,8 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
@@ -19,8 +18,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
@@ -58,9 +55,8 @@ class ProviderWriter internal constructor(
store: RoomTasksDataSource,
source: ProviderSnapshotSource,
resolver: ProviderResolver,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
reminders: ReminderRefresher,
) : this(database, store, source, resolver, reminders::requestRefresh)
private var batchDepth = 0
private var batchWrote = false
@@ -76,6 +76,7 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>,
}
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
@@ -650,8 +651,12 @@ class RoomTasksDataSource @Inject constructor(
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange)
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange)
private fun observe(tables: Array<String>, onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(tables) {
override fun onInvalidated(tables: Set<String>) = onChange()
}
database.invalidationTracker.addObserver(observer)
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority
@@ -109,7 +108,6 @@ data class TaskEditUiState(
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
@@ -446,13 +444,6 @@ class TaskEditViewModel @Inject constructor(
scope = scope,
)
}
// ⚠️ Outside the write's own result. The task is saved by this
// point, and reporting a scheduling failure as a *save* failure
// sends the user back to tap Save again — on a screen whose
// editingTaskId is still null, which creates a second task. The
// reminder is re-synced on the next data change and on launch;
// the duplicate is forever.
runCatching { reminderScheduler.sync() }
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error ->
@@ -12,6 +12,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -23,8 +25,10 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.LauncherMark
import de.jeanlucmakiola.floret.components.LauncherNameChooser
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -106,15 +110,25 @@ internal fun AppearanceScreen(
}
if (showAppName) {
OptionPicker(
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) },
onSelect = viewModel::setLauncherName,
onDismiss = { showAppName = false },
)
predictiveBack = true,
) {
LauncherNameChooser(
summary = stringResource(R.string.settings_app_name_hint),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
onSelect = viewModel::setLauncherName,
mark = {
LauncherMark(
background = colorResource(R.color.ic_launcher_background),
foreground = painterResource(R.drawable.ic_launcher_foreground),
)
},
)
}
}
if (showTimeFormat) {
@@ -188,7 +188,6 @@ class SettingsViewModel @Inject constructor(
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,
@@ -212,7 +211,6 @@ class SettingsViewModel @Inject constructor(
retryingLegacyImport.value = true
viewModelScope.launch {
runCatching { oneShotImport.reimportFromArchive() }
runCatching { reminderScheduler.sync() }
retryingLegacyImport.value = false
}
}
@@ -3,14 +3,11 @@ package de.jeanlucmakiola.agendula.widget
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.glance.WidgetRollover
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,16 +23,10 @@ class TaskWidgetUpdater @Inject constructor(
private val dataSource: TasksDataSource,
@ApplicationScope private val scope: CoroutineScope,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private var observer: AutoCloseable? = null
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw)
@OptIn(FlowPreview::class)
fun start() {
if (observer != null) return
observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull()
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
}
if (!changes.start()) return
scope.launch(Dispatchers.IO) {
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
}
@@ -44,9 +35,7 @@ class TaskWidgetUpdater @Inject constructor(
}
/** Redraw soon; bursts collapse into one pass. */
fun requestRefresh() {
requests.trySend(Unit)
}
fun requestRefresh() = changes.request()
private suspend fun redraw() {
runCatching {
+1
View File
@@ -832,6 +832,7 @@
<string name="add_account_certificate_issuer">Issued by</string>
<string name="add_account_certificate_expires">Valid until</string>
<string name="add_account_certificate_trust">Trust this certificate</string>
<string name="add_account_certificate_decline">Don\u2019t trust it</string>
<string name="add_account_lists_title">Which lists should sync?</string>
<string name="add_account_lists_read_only">Read-only</string>
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import java.lang.reflect.Proxy
import kotlin.time.Instant
class OccurrenceLookupTest {
private val yesterday = Instant.fromEpochMilliseconds(1_800_000_000_000)
private val today = Instant.fromEpochMilliseconds(1_800_000_000_000 + 86_400_000)
private val current = testTask(id = 10).copy(seriesId = 10, occurrenceStart = today)
/** Answers only the two lookups [occurrence] makes. */
private fun source(byAnchor: (Instant?) -> Task?, list: List<Task> = emptyList()): TasksDataSource =
Proxy.newProxyInstance(TasksDataSource::class.java.classLoader, arrayOf(TasksDataSource::class.java)) { _, method, args ->
when {
method.name == "task" && args?.size == 2 -> byAnchor(args[1] as Instant?)
method.name == "tasks" -> list
else -> throw UnsupportedOperationException(method.name)
}
} as TasksDataSource
@Test
fun `a ticked occurrence is found although it is a row of its own`() {
val ticked = testTask(id = 57, status = TaskStatus.COMPLETED, completedAt = today)
.copy(seriesId = 10, occurrenceStart = yesterday)
val found = source({ if (it == yesterday) ticked else current }).occurrence(10, yesterday.toEpochMilliseconds())
assertThat(found).isEqualTo(ticked)
assertThat(reminderIsStale(yesterday.toEpochMilliseconds(), found, missingMeansGone = true)).isTrue()
}
@Test
fun `an exception row the anchored lookup misses is found in the list`() {
val exception = testTask(id = 99, status = TaskStatus.COMPLETED, completedAt = today)
.copy(seriesId = 10, occurrenceStart = yesterday)
val found = source({ current }, listOf(current, exception)).occurrence(10, yesterday.toEpochMilliseconds())
assertThat(found).isEqualTo(exception)
}
}
@@ -44,6 +44,14 @@ class ReminderDiffTest {
assertThat(diff.keep).containsExactly(a)
}
@Test
fun `a reopened task within the grace does not ring again`() {
// Ticked, so the store dropped it; undone, so it is wanted again.
val diff = ReminderDiff.of(previous = emptySet(), desired = setOf(a), fired = setOf(a)) { false }
assertThat(diff.arm).isEmpty()
assertThat(diff.keep).containsExactly(a)
}
@Test
fun `store entries round-trip, and junk is skipped`() {
val encoded = ScheduledReminderStore.encode(setOf(a, b))
@@ -0,0 +1,54 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ReminderIsStaleTest {
private val none = ScheduledReminder.NO_OCCURRENCE
private val anchor = Instant.fromEpochMilliseconds(1_800_000_000_000)
@Test
fun `a deleted task's reminder comes down`() {
assertThat(reminderIsStale(none, null, missingMeansGone = true)).isTrue()
}
@Test
fun `a completed task's reminder comes down`() {
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
assertThat(reminderIsStale(none, done, missingMeansGone = true)).isTrue()
}
@Test
fun `an open task's reminder stays`() {
assertThat(reminderIsStale(none, testTask(), missingMeansGone = true)).isFalse()
}
@Test
fun `a completed occurrence's reminder comes down`() {
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor).copy(occurrenceStart = anchor)
assertThat(reminderIsStale(anchor.toEpochMilliseconds(), done, missingMeansGone = true)).isTrue()
}
@Test
fun `a lookup that fell back to another occurrence leaves the reminder up`() {
val other = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
.copy(occurrenceStart = Instant.fromEpochMilliseconds(anchor.toEpochMilliseconds() + 86_400_000))
assertThat(reminderIsStale(anchor.toEpochMilliseconds(), other, missingMeansGone = true)).isFalse()
}
@Test
fun `a failed provider read leaves the reminder up`() {
assertThat(reminderIsStale(none, null, missingMeansGone = false)).isFalse()
}
@Test
fun `a reminder from before occurrences were recorded comes down only for a task that does not recur`() {
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
assertThat(reminderIsStale(null, done, missingMeansGone = true)).isTrue()
assertThat(reminderIsStale(null, done.copy(occurrenceStart = anchor), missingMeansGone = true)).isFalse()
}
}
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.agendula.data.sync
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.io.File
/**
* `network_security_config.xml` permits cleartext app-wide, and the per-account
* opt-in is enforced only by the clients `CalDavHttp` builds. Any HTTP client
* built elsewhere would speak plain http without asking.
*/
class HttpClientConstructionTest {
private val forbidden = listOf(
"OkHttpClient()",
"OkHttpClient.Builder()",
"HttpURLConnection",
"HttpsURLConnection",
".openConnection(",
".openStream(",
)
@Test
fun `every HTTP client is built through CalDavHttp`() {
val roots = listOf(File("src"), File("../caldav/src/main"))
assertThat(roots.filter { it.isDirectory }).isNotEmpty()
val offenders = roots
.filter { it.isDirectory }
.flatMap { root -> root.walk().filter { it.isFile && it.extension in setOf("kt", "java") }.toList() }
.filterNot { "/test" in it.invariantSeparatorsPath || "/androidTest" in it.invariantSeparatorsPath }
.filterNot { it.name == "CalDavHttp.kt" }
.flatMap { file ->
file.readLines().mapIndexedNotNull { index, line ->
forbidden.firstOrNull { it in line }?.let { "${file.path}:${index + 1}: $it" }
}
}
assertThat(offenders).isEmpty()
}
}
@@ -1525,18 +1525,52 @@ class AddAccountViewModelTest {
}
@Test
fun `a certificate already pinned is not asked about again`() = runTest(dispatcher) {
fun `a certificate trusted for one address is not carried to another`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = null
val vm = viewModel()
vm.enterAddress("https://a.lan")
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onCertificateTrusted()
advanceUntilIdle()
assertThat(vm.onBackWithin()).isTrue()
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
vm.enterAddress("https://b.example.com")
vm.onAddressSubmitted()
advanceUntilIdle()
assertThat(gateway.discoveries.last().trust).isEqualTo(ServerTrust.DEFAULT)
}
@Test
fun `declining a certificate is offered without the back gesture`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
val vm = viewModel()
vm.enterAddress("https://nas.home.arpa")
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onCertificateTrusted()
assertThat(vm.onBackWithin()).isTrue()
advanceUntilIdle()
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("https://nas.home.arpa")
assertThat(gateway.discoveries).hasSize(1)
}
@Test
fun `an email whose domain will not set up TLS asks for the server`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(CalDavDiscovery.Outcome.Cause.TLS, "tls")
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
vm.onAddressChanged("me@mailbox.org")
vm.onAddressSubmitted()
advanceUntilIdle()
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).error)
.isEqualTo(CalDavDiscovery.Outcome.Cause.TLS)
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isTrue()
assertThat(step.error).isNull()
}
@Test
@@ -1553,6 +1587,42 @@ class AddAccountViewModelTest {
assertThat(gateway.discoveries.single().trust).isEqualTo(stored)
}
@Test
fun `a stored trust that loads late is not applied to an edited address`() = runTest(dispatcher) {
creator.storedTrust[9] = ServerTrust(allowCleartext = true)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
CalDavDiscovery.Outcome.Cause.INSECURE,
"http",
)
val vm = viewModel()
vm.startReauthentication(9, "http://nas.local/dav/principals/me/", "me")
// Submitted before the stored trust has been read.
vm.onAddressChanged("http://other.local")
vm.onAddressSubmitted()
advanceUntilIdle()
assertThat(gateway.discoveries.single().trust).isEqualTo(ServerTrust.DEFAULT)
assertThat(vm.state.value.step).isEqualTo(AddAccountStep.ConfirmCleartext("http://other.local"))
}
@Test
fun `a renewed certificate replaces the one the account trusted`() = runTest(dispatcher) {
creator.storedTrust[9] = ServerTrust(pinnedCertificates = setOf("cd".repeat(32)))
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = null
val vm = viewModel()
vm.startReauthentication(9, "https://nas.home.arpa/dav/principals/me/", "me")
advanceUntilIdle()
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onCertificateTrusted()
advanceUntilIdle()
assertThat(gateway.discoveries.last().trust.pinnedCertificates)
.containsExactly(certificate.fingerprint)
}
@Test
fun `starting over forgets what was trusted`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate)
@@ -157,6 +157,11 @@ class CalDavDiscovery(
* A 401 stops the walk immediately: it means we found a DAV server and simply
* have no credentials for it, and continuing down the ladder would replace a
* precise "sign in" with a vague "nothing found".
*
* An untrusted certificate does not: a lower-priority SRV target may answer
* with one the phone trusts, so it is only offered once the ladder is spent.
* On a guessed host — the email's own domain, with no SRV record — it is not
* offered at all, since that host is most likely the domain's website.
*/
fun discover(input: String): Outcome {
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
@@ -174,17 +179,31 @@ class CalDavDiscovery(
}
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered")
var untrusted: Outcome.UntrustedCertificate? = null
// Same host, same certificate: the remaining rungs on it would only fail again.
val refusedOrigins = mutableSetOf<String>()
for (candidate in candidates) {
if (candidate.url.originKey() in refusedOrigins) continue
when (val outcome = probe(candidate.url)) {
is Outcome.Found,
is Outcome.NeedsAuthentication,
Outcome.Unauthenticated,
is Outcome.UntrustedCertificate,
-> return outcome
is Outcome.UntrustedCertificate -> {
refusedOrigins += candidate.url.originKey()
if (candidate.guessed) {
lastFailure = Outcome.Failed(
Outcome.Cause.TLS,
"untrusted certificate on guessed host ${candidate.url.host}",
)
} else if (untrusted == null) {
untrusted = outcome
}
}
else -> lastFailure = outcome
}
}
return lastFailure
return untrusted ?: lastFailure
}
internal fun probe(base: HttpUrl): Outcome {
@@ -354,6 +373,8 @@ class CalDavDiscovery(
untrustedCertificateIn(error)?.let { Outcome.UntrustedCertificate(ServerCertificate.of(it)) }
?: Outcome.Failed(causeOf(error), error.message ?: error.toString())
private fun HttpUrl.originKey() = "$scheme://$host:$port"
private fun isUnauthorized(error: Throwable): Boolean =
error is at.bitfire.dav4jvm.exception.UnauthorizedException
@@ -9,7 +9,6 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.GzipSource
import okio.buffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
@@ -138,7 +137,8 @@ object CalDavHttp {
)
}
private class Tls(trust: ServerTrust) {
private class Tls(pins: Set<String>) {
private val trust = ServerTrust(pinnedCertificates = pins)
val trustManager = PinningTrustManager(trust)
val socketFactory: SSLSocketFactory = SSLContext.getInstance("TLS")
.apply { init(null, arrayOf(trustManager), null) }
@@ -147,13 +147,21 @@ object CalDavHttp {
}
/**
* One socket factory per distinct trust, because OkHttp only pools a
* One socket factory per distinct pin set, because OkHttp only pools a
* connection between clients whose factory and verifier are the same
* instances — a fresh one per call would reopen TLS for every client.
* Keyed on the pins alone (cleartext has no bearing on TLS), and bounded,
* since every abandoned setup attempt leaves a pin set behind.
*/
private val tlsCache = ConcurrentHashMap<ServerTrust, Tls>()
private val tlsCache = object : LinkedHashMap<Set<String>, Tls>(TLS_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Set<String>, Tls>) = size > TLS_CACHE_SIZE
}
private fun tlsFor(trust: ServerTrust): Tls = tlsCache.getOrPut(trust) { Tls(trust) }
private fun tlsFor(trust: ServerTrust): Tls = synchronized(tlsCache) {
tlsCache.getOrPut(trust.pinnedCertificates) { Tls(trust.pinnedCertificates) }
}
private const val TLS_CACHE_SIZE = 16
/**
* The two headers that decide whether ETags are usable and whether our bytes
@@ -51,8 +51,14 @@ object ServiceDiscovery {
private const val SRV_SERVICE = "_caldavs._tcp"
private const val WELL_KNOWN = "/.well-known/caldav"
/** A URL to probe, and where it came from — the "why" a failure report needs. */
data class Candidate(val url: HttpUrl, val origin: String)
/**
* A URL to probe, and where it came from — the "why" a failure report needs.
*
* @property guessed the host is the email's domain taken on spec, with no
* SRV record naming it. Usually that is the domain's website, so its
* certificate is not one worth asking the user to trust.
*/
data class Candidate(val url: HttpUrl, val origin: String, val guessed: Boolean = false)
/**
* Hosts whose SRV record leads somewhere that is not a DAV server.
@@ -149,16 +155,15 @@ object ServiceDiscovery {
// explicit, which is the whole point of Posteo's 8443.
val port = if (record.port == 443) "" else ":${record.port}"
"https://$host$port" to "SRV $host$port"
}.ifEmpty {
listOf("https://$domain" to "domain as typed")
}
val guessed = origins.isEmpty()
for ((origin, label) in origins) {
for ((origin, label) in origins.ifEmpty { listOf("https://$domain" to "domain as typed") }) {
if (txtPath != null) {
add(candidates, origin, txtPath, "$label + TXT path=$txtPath")
add(candidates, origin, txtPath, "$label + TXT path=$txtPath", guessed)
}
add(candidates, origin, WELL_KNOWN, "$label + .well-known")
add(candidates, origin, "/", "$label + root")
add(candidates, origin, WELL_KNOWN, "$label + .well-known", guessed)
add(candidates, origin, "/", "$label + root", guessed)
}
return candidates
}
@@ -181,9 +186,15 @@ object ServiceDiscovery {
return url.topPrivateDomain() ?: url.host
}
private fun add(into: MutableList<Candidate>, origin: String, path: String, label: String) {
private fun add(
into: MutableList<Candidate>,
origin: String,
path: String,
label: String,
guessed: Boolean = false,
) {
val url = (origin.trimEnd('/') + "/" + path.trimStart('/')).toHttpUrlOrNull() ?: return
if (into.none { it.url == url }) into += Candidate(url, label)
if (into.none { it.url == url }) into += Candidate(url, label, guessed)
}
/**
@@ -12,6 +12,7 @@ import org.junit.Before
import org.junit.Test
import java.io.IOException
import java.net.ConnectException
import java.net.InetAddress
import java.net.UnknownServiceException
import java.security.cert.CertificateException
import javax.net.ssl.SSLContext
@@ -160,4 +161,51 @@ class ServerTrustTest {
assertThat(discovery.probe(server.url("/dav/")))
.isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
}
@Test
fun `an untrusted SRV target does not stop the ladder before one that answers`() {
val loopback = InetAddress.getByName("127.0.0.1")
fun tlsServer(certificate: HeldCertificate) = MockWebServer().apply {
useHttps(HandshakeCertificates.Builder().heldCertificate(certificate).build().sslSocketFactory(), false)
start(loopback, 0)
}
val untrusted = tlsServer(selfSigned("127.0.0.1"))
val pinned = selfSigned("127.0.0.1")
val answering = tlsServer(pinned).apply {
enqueue(options())
enqueue(MockResponse().setResponseCode(401))
}
val dns = object : DnsResolver {
override fun lookup(name: String) = DnsRecords(
srv = listOf(
SrvRecord(priority = 0, weight = 0, port = untrusted.port, target = "127.0.0.1."),
SrvRecord(priority = 10, weight = 0, port = answering.port, target = "127.0.0.1."),
),
txt = emptyList(),
)
}
val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(pinned.certificate))
try {
val outcome = CalDavDiscovery(CalDavHttp.anonymous("test", trust), dns).discover("me@127.0.0.1")
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
} finally {
untrusted.shutdown()
answering.shutdown()
}
}
@Test
fun `the email's own domain is a guess, and an SRV target is not`() {
assertThat(ServiceDiscovery.candidatesFor("me@example.com").all { it.guessed }).isTrue()
val dns = object : DnsResolver {
override fun lookup(name: String) = DnsRecords(
srv = listOf(SrvRecord(priority = 0, weight = 0, port = 443, target = "dav.example.com.")),
txt = emptyList(),
)
}
assertThat(ServiceDiscovery.candidatesFor("me@example.com", dns).none { it.guessed }).isTrue()
assertThat(ServiceDiscovery.candidatesFor("https://example.com").none { it.guessed }).isTrue()
}
}