Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8472c65f | ||
|
|
c2320de60b | ||
|
|
62ee105f9d | ||
|
|
b1abcecd06 | ||
|
|
ba48a9821c | ||
|
|
fd8e4c9450 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+111
-64
@@ -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
|
||||
}
|
||||
}
|
||||
+47
-12
@@ -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)
|
||||
}
|
||||
+1
-4
@@ -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"
|
||||
|
||||
|
||||
+9
-3
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
+74
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule floret-kit updated: e8d33329c8...a26bea7871.
Reference in new issue
Block a user