Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f86dc46bb | ||
|
|
ffc5900db4 |
@@ -50,6 +50,22 @@ All notable changes to this project are documented here. The format follows
|
||||
Notifications, Task form is now New task form, and export and import moved
|
||||
out of Storage into a new Backup & restore section (#66).
|
||||
|
||||
### Fixed
|
||||
- Fixed a crash when a task's date was shown in Russian and other languages
|
||||
whose dates put a word after the year (#70).
|
||||
- Widgets no longer turn into the Today widget or show "Can't load content"
|
||||
after a task is ticked off (#92).
|
||||
- The Tasks widget leaves the same gap under its header as its preview (#86).
|
||||
- The Starts and Due labels in the task form no longer get cut off at large
|
||||
font sizes (#76).
|
||||
- Adding an account by email address finds the server again for providers
|
||||
that publish it in DNS, such as mailbox.org and Posteo. The DNS lookup never
|
||||
reached the network, so they ended in "isn't a CalDAV server". It now goes
|
||||
through Android's resolver and respects Private DNS (#97).
|
||||
- When the server can't be found from your email address, account setup asks
|
||||
for the server address straight away and keeps your email as the username,
|
||||
instead of stopping at an error (#97).
|
||||
|
||||
## [1.1.0] - 2026-10-10
|
||||
|
||||
### 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 = 1020001
|
||||
versionName = "1.2.0-beta.1"
|
||||
versionCode = 1020002
|
||||
versionName = "1.2.0-beta.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
Vendored
+5
@@ -20,3 +20,8 @@
|
||||
-dontwarn javax.naming.**
|
||||
-dontwarn lombok.Generated
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
# Glance maps each widget to its receivers by the provider's class name. R8's
|
||||
# class merging folded the GlanceAppWidget subclasses into one class, so they
|
||||
# all shared a name and redrew each other's slots (#92).
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget { <init>(); }
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.caldav.AppPassword
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.DnsJavaResolver
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -75,7 +75,8 @@ class OkHttpCalDavGateway @Inject constructor(
|
||||
val client = credentials?.let {
|
||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
||||
} ?: CalDavHttp.anonymous(userAgent)
|
||||
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
|
||||
val job = coroutineContext.job
|
||||
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target)
|
||||
}
|
||||
|
||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.net.DnsResolver as PlatformDnsResolver
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Looper
|
||||
import de.jeanlucmakiola.caldav.DnsAnswers
|
||||
import de.jeanlucmakiola.caldav.DnsRecords
|
||||
import de.jeanlucmakiola.caldav.DnsResolver
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* SRV/TXT lookups through the system resolver, so discovery uses the current
|
||||
* network's DNS and honours Private DNS rather than sending plain queries of
|
||||
* its own. A failed or timed-out lookup reads as no records.
|
||||
*
|
||||
* The platform delivers answers through the main looper, so this must run on a
|
||||
* worker thread; waiting on the main one would deadlock until the timeout.
|
||||
* The wait gives up as soon as [isCancelled] says so, and answered lookups are
|
||||
* kept briefly so the authenticated walk does not ask again.
|
||||
*/
|
||||
class SystemDnsResolver(
|
||||
private val isCancelled: () -> Boolean = { false },
|
||||
) : DnsResolver {
|
||||
|
||||
override fun lookup(name: String): DnsRecords {
|
||||
check(!Looper.getMainLooper().isCurrentThread) { "DNS lookup on the main thread" }
|
||||
val now = System.nanoTime()
|
||||
cache[name]?.takeIf { now - it.at < CACHE_NANOS }?.let { return it.records }
|
||||
val srv = Query(name, DnsAnswers.TYPE_SRV)
|
||||
val txt = Query(name, DnsAnswers.TYPE_TXT)
|
||||
val answered = try {
|
||||
val deadline = now + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
|
||||
srv.await(deadline, isCancelled) && txt.await(deadline, isCancelled)
|
||||
} finally {
|
||||
srv.cancel()
|
||||
txt.cancel()
|
||||
}
|
||||
val records = DnsRecords(
|
||||
srv.answer()?.let(DnsAnswers::srv).orEmpty(),
|
||||
txt.answer()?.let(DnsAnswers::txt).orEmpty(),
|
||||
)
|
||||
if (answered) cache[name] = Cached(now, records)
|
||||
return records
|
||||
}
|
||||
|
||||
private class Cached(val at: Long, val records: DnsRecords)
|
||||
|
||||
private class Query(name: String, type: Int) {
|
||||
private val done = CountDownLatch(1)
|
||||
private val signal = CancellationSignal()
|
||||
private val result = AtomicReference<ByteArray?>()
|
||||
|
||||
init {
|
||||
try {
|
||||
PlatformDnsResolver.getInstance().rawQuery(
|
||||
null,
|
||||
name,
|
||||
PlatformDnsResolver.CLASS_IN,
|
||||
type,
|
||||
PlatformDnsResolver.FLAG_EMPTY,
|
||||
Runnable::run,
|
||||
signal,
|
||||
object : PlatformDnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
result.set(answer)
|
||||
done.countDown()
|
||||
}
|
||||
|
||||
override fun onError(error: PlatformDnsResolver.DnsException) {
|
||||
done.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: RuntimeException) {
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the query finished before [deadline] and before [isCancelled]. */
|
||||
fun await(deadline: Long, isCancelled: () -> Boolean): Boolean {
|
||||
while (!isCancelled()) {
|
||||
val left = deadline - System.nanoTime()
|
||||
if (left <= 0) return false
|
||||
if (done.await(minOf(left, SLICE_NANOS), TimeUnit.NANOSECONDS)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun cancel() = signal.cancel()
|
||||
|
||||
fun answer(): ByteArray? = result.get()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 10L
|
||||
val SLICE_NANOS = TimeUnit.MILLISECONDS.toNanos(100)
|
||||
val CACHE_NANOS = TimeUnit.MINUTES.toNanos(1)
|
||||
val cache = ConcurrentHashMap<String, Cached>()
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,9 @@ private fun StepTitle(step: AddAccountStep) {
|
||||
R.string.add_account_provider_title to R.string.add_account_provider_body
|
||||
is AddAccountStep.PrepareAccess ->
|
||||
step.quirk.setupTitle to R.string.add_account_setup_body
|
||||
is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) {
|
||||
is AddAccountStep.EnterAddress -> if (step.manualServer) {
|
||||
R.string.add_account_server_title to R.string.add_account_server_fallback_body
|
||||
} else if (step.asksForEmail) {
|
||||
R.string.add_account_email_title to R.string.add_account_email_body
|
||||
} else {
|
||||
R.string.add_account_server_title to R.string.add_account_server_body
|
||||
|
||||
+83
-19
@@ -77,7 +77,12 @@ sealed interface AddAccountStep {
|
||||
val input: String = "",
|
||||
/** A cause, not a message — the screen owns the wording. */
|
||||
val error: CalDavDiscovery.Outcome.Cause? = null,
|
||||
) : AddAccountStep
|
||||
/** The email address found no server, so a hosted choice asks for its URL instead. */
|
||||
val manualServer: Boolean = false,
|
||||
) : AddAccountStep {
|
||||
/** Whether the field takes an email address rather than a server URL. */
|
||||
val asksForEmail: Boolean get() = choice.provider?.hosted == true && !manualServer
|
||||
}
|
||||
|
||||
data class Working(val message: AddAccountMessage.Progress) : AddAccountStep
|
||||
|
||||
@@ -279,6 +284,11 @@ class AddAccountViewModel @Inject constructor(
|
||||
get() = handle[KEY_REAUTH_ACCOUNT]
|
||||
set(value) { handle[KEY_REAUTH_ACCOUNT] = value }
|
||||
|
||||
/** The email that found no server; Back returns to it from the server field. */
|
||||
private var fallbackEmail: String?
|
||||
get() = handle[KEY_FALLBACK_EMAIL]
|
||||
set(value) { handle[KEY_FALLBACK_EMAIL] = value }
|
||||
|
||||
/** The username the credentials step starts with on a re-authentication. */
|
||||
private var suggestedUsername: String
|
||||
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
|
||||
@@ -370,6 +380,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
// one is not an answer to this one.
|
||||
if (picked != choice) addressInput = ""
|
||||
choice = picked
|
||||
clearFallback()
|
||||
val quirk = picked.provider?.let(ServerQuirk::forProvider)
|
||||
val setup = quirk?.hasSetupSteps == true
|
||||
updateState {
|
||||
@@ -430,11 +441,10 @@ class AddAccountViewModel @Inject constructor(
|
||||
addressInput = value
|
||||
updateState {
|
||||
it.copy(
|
||||
step = AddAccountStep.EnterAddress(picked, value),
|
||||
step = addressStep(value),
|
||||
// The service's own rule outranks the one the typed host implies:
|
||||
// it was chosen deliberately, and it is known before a key is hit.
|
||||
quirk = picked.provider?.let(ServerQuirk::forProvider)
|
||||
?: ServerQuirk.forInput(value),
|
||||
quirk = quirkFor(value),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -505,13 +515,47 @@ class AddAccountViewModel @Inject constructor(
|
||||
// would make it permanently unreachable.
|
||||
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
|
||||
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email address whose domain has no CalDAV server goes straight to the
|
||||
* server field rather than an error with nowhere to go; the email stays as
|
||||
* the username. A domain nothing answered for may also be a typo or no
|
||||
* network, so that message stays visible on the server field. Any other
|
||||
* failure keeps its own message on the email field.
|
||||
*/
|
||||
private fun backToAddressOrServer(cause: CalDavDiscovery.Outcome.Cause) {
|
||||
val email = ServiceDiscovery.emailOf(typedInput)
|
||||
if (fallbackEmail != null || email == null || cause !in NO_SERVER_FOUND) {
|
||||
return backToAddress(cause)
|
||||
}
|
||||
fallbackEmail = email
|
||||
addressInput = ""
|
||||
updateState {
|
||||
it.copy(
|
||||
step = addressStep("", cause.takeIf { it == CalDavDiscovery.Outcome.Cause.UNREACHABLE }),
|
||||
quirk = quirkFor(""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearFallback() {
|
||||
fallbackEmail = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The username the credentials step starts with: a re-authentication's, else
|
||||
* the email that fell back to the server field — or one typed into it.
|
||||
*/
|
||||
private fun prefilledUsername(): String = suggestedUsername.ifBlank {
|
||||
fallbackEmail?.let { ServiceDiscovery.emailOf(typedInput) ?: it }.orEmpty()
|
||||
}
|
||||
|
||||
/** Step 3a → re-run discovery with the password the user typed. */
|
||||
fun onCredentialsSubmitted() {
|
||||
val step = current.step as? AddAccountStep.EnterCredentials ?: return
|
||||
@@ -549,8 +593,8 @@ class AddAccountViewModel @Inject constructor(
|
||||
|
||||
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
||||
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,7 +621,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
updateState {
|
||||
it.copy(
|
||||
step = AddAccountStep.EnterCredentials(
|
||||
username = suggestedUsername,
|
||||
username = prefilledUsername(),
|
||||
error = quirkHint(),
|
||||
),
|
||||
)
|
||||
@@ -803,7 +847,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
pollJob?.cancel()
|
||||
updateState {
|
||||
it.copy(
|
||||
step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null),
|
||||
step = AddAccountStep.EnterCredentials(username = prefilledUsername(), error = null),
|
||||
openInBrowser = null,
|
||||
)
|
||||
}
|
||||
@@ -916,7 +960,17 @@ class AddAccountViewModel @Inject constructor(
|
||||
// Back into the errand when there was one, so the steps can be re-read
|
||||
// without starting the flow again — that is the screen people return to.
|
||||
is AddAccountStep.EnterAddress -> {
|
||||
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
|
||||
val email = fallbackEmail
|
||||
if (email != null) {
|
||||
clearFallback()
|
||||
addressInput = email
|
||||
typedInput = email
|
||||
updateState {
|
||||
it.copy(step = addressStep(email), fatal = null, quirk = quirkFor(email))
|
||||
}
|
||||
} else {
|
||||
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -990,6 +1044,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
hostsNeedingAuth = emptyList()
|
||||
reauthAccountId = null
|
||||
suggestedUsername = ""
|
||||
fallbackEmail = null
|
||||
current = AddAccountUiState()
|
||||
}
|
||||
|
||||
@@ -1119,8 +1174,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
reauthenticating = reauthAccountId != null,
|
||||
// Derived rather than saved: it is a pure function of the choice and the
|
||||
// address, both of which are.
|
||||
quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
||||
?: ServerQuirk.forInput(addressInput),
|
||||
quirk = quirkFor(addressInput),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1148,8 +1202,8 @@ class AddAccountViewModel @Inject constructor(
|
||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||
when (val outcome = gateway.discover(target, credentials)) {
|
||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
// The credential we were holding is no longer accepted, which on
|
||||
// this path is indistinguishable from never having had one.
|
||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||
@@ -1250,6 +1304,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
choice = choice ?: ProviderChoice.OtherServer,
|
||||
input = input,
|
||||
error = error,
|
||||
manualServer = fallbackEmail != null,
|
||||
)
|
||||
|
||||
/** What was actually set up, named the way the accounts list will name it. */
|
||||
@@ -1286,12 +1341,13 @@ class AddAccountViewModel @Inject constructor(
|
||||
updateState { it.copy(step = transform(step)) }
|
||||
}
|
||||
|
||||
/** The chosen service's quirk, else the one the typed host implies. */
|
||||
private fun quirkFor(input: String): ServerQuirk? =
|
||||
choice?.provider?.let(ServerQuirk::forProvider) ?: ServerQuirk.forInput(input)
|
||||
|
||||
/** The provider-specific reason a correct-looking password gets rejected. */
|
||||
private fun quirkHint(): AddAccountMessage? =
|
||||
when (
|
||||
val quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
||||
?: ServerQuirk.forInput(typedInput)
|
||||
) {
|
||||
when (val quirk = quirkFor(typedInput)) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD,
|
||||
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
|
||||
-> AddAccountMessage.Quirk(quirk)
|
||||
@@ -1377,6 +1433,14 @@ class AddAccountViewModel @Inject constructor(
|
||||
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
|
||||
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
||||
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
||||
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
|
||||
|
||||
/** 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,
|
||||
)
|
||||
|
||||
/**
|
||||
* What to type for a server, from the principal an account stored.
|
||||
|
||||
@@ -20,7 +20,7 @@ import de.jeanlucmakiola.agendula.R
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
|
||||
val hosted = step.choice.provider?.hosted == true
|
||||
val hosted = step.asksForEmail
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
FieldRow(
|
||||
label = if (hosted) {
|
||||
@@ -30,7 +30,7 @@ internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccoun
|
||||
},
|
||||
value = step.input,
|
||||
onValueChange = viewModel::onAddressChanged,
|
||||
placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" }
|
||||
placeholder = step.choice.provider?.primaryDomain?.takeIf { hosted }?.let { "you@$it" }
|
||||
?: stringResource(R.string.add_account_server_hint),
|
||||
error = step.error?.let { stringResource(it.message) },
|
||||
// An email address is still typed with the URI keyboard: it is the
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -883,6 +884,7 @@ private fun InlineField(
|
||||
* affordance appears once a value is set. Tappable values read as links
|
||||
* (primary); an error flips them to the error colour.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ScheduleRow(
|
||||
label: String,
|
||||
@@ -893,43 +895,76 @@ private fun ScheduleRow(
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (value == null) {
|
||||
val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp)
|
||||
LabelledValueLayout(
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_set),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = value.formatDate(allDay),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!allDay) {
|
||||
Text(
|
||||
text = value.formatTime(LocalUse24HourFormat.current),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Rounded.Clear,
|
||||
contentDescription = stringResource(R.string.edit_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
value = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (value == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_set),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = valueModifier,
|
||||
)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
Text(
|
||||
text = value.formatDate(allDay),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = valueModifier,
|
||||
)
|
||||
if (!allDay) {
|
||||
Text(
|
||||
text = value.formatTime(LocalUse24HourFormat.current),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = valueModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Rounded.Clear,
|
||||
contentDescription = stringResource(R.string.edit_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [label] at the start and [value] at the end of one line when both fit, else
|
||||
* [value] on its own line below, still end-aligned — so large font scales wrap
|
||||
* rather than clip either side (#76).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LabelledValueLayout(label: @Composable () -> Unit, value: @Composable () -> Unit) {
|
||||
FlowRow(
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
label()
|
||||
// Its whole width as its minimum, so the value wraps as one piece.
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(IntrinsicSize.Max)) { value() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,12 +183,15 @@ private fun TaskWidgetBody(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
TaskWidgetHeader(title, filter, ready?.singleListId)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
when (data) {
|
||||
null -> Unit
|
||||
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
TaskWidgetData.Unavailable -> {
|
||||
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
||||
WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
}
|
||||
is TaskWidgetData.Ready ->
|
||||
if (data.rows.isEmpty()) {
|
||||
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||
} else {
|
||||
val allSections = widgetSections(
|
||||
@@ -213,6 +216,12 @@ private fun TaskWidgetBody(
|
||||
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
|
||||
onlyHeader.key == "due:${redundantDueKind.name}"
|
||||
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
|
||||
val leadingGap = if (sections.firstOrNull() is WidgetSection.TaskItem) {
|
||||
R.dimen.widget_tasks_header_gap
|
||||
} else {
|
||||
R.dimen.widget_tasks_header_gap_tight
|
||||
}
|
||||
Spacer(GlanceModifier.height(leadingGap))
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(sections.size) { index ->
|
||||
when (val section = sections[index]) {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginTop="@dimen/widget_tasks_header_gap"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
|
||||
@@ -718,6 +718,7 @@
|
||||
<string name="add_account_summary_body">Agendula will keep these lists in sync from now on. You can change what syncs in Settings.</string>
|
||||
<string name="add_account_summary_done">Done</string>
|
||||
<string name="add_account_server_title">Where are your tasks?</string>
|
||||
<string name="add_account_server_fallback_body">Agendula couldn\u2019t find the server from your email address. Enter the CalDAV server address your provider gives for calendars instead.</string>
|
||||
<string name="add_account_server_body">Enter the address of your CalDAV server. An email address works too, if the server publishes itself under its domain.</string>
|
||||
<string name="add_account_credentials_title">Sign in</string>
|
||||
<string name="add_account_credentials_body">Use an app password if your provider offers one \u2014 it can be revoked without changing your account password.</string>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Header to first task row in the Tasks widget; shared with its picker preview. -->
|
||||
<dimen name="widget_tasks_header_gap">8dp</dimen>
|
||||
<!-- Header to anything that brings its own top spacing (a section label, a message). -->
|
||||
<dimen name="widget_tasks_header_gap_tight">2dp</dimen>
|
||||
</resources>
|
||||
+220
@@ -218,6 +218,226 @@ class AddAccountViewModelTest {
|
||||
// The input survives, so the user can correct it rather than retype it.
|
||||
assertThat(step.input).isEqualTo("https://example.com/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email that finds no server asks for the server address instead`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405 on mailbox.org",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.input).isEmpty()
|
||||
assertThat(step.error).isNull()
|
||||
|
||||
vm.onAddressChanged("https://dav.mailbox.org/")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
// The email the user already gave is the username, not retyped.
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back from the server fallback returns to the email`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405 on mailbox.org",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.onBackWithin()).isTrue()
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isFalse()
|
||||
assertThat(step.input).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure that is not a missing server keeps its error on the email`() = runTest(dispatcher) {
|
||||
for (cause in listOf(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS,
|
||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
|
||||
)) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(cause, "$cause")
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isFalse()
|
||||
assertThat(step.error).isEqualTo(cause)
|
||||
assertThat(step.input).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email nothing answered for asks for the server and keeps the error`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
|
||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
|
||||
"no route",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("me@mail-only.example")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.input).isEmpty()
|
||||
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email that asked for a password first still reaches the server field`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("example.com"))
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("me@example.com")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onUsernameChanged("me@example.com")
|
||||
vm.onPasswordChanged("secret")
|
||||
vm.onCredentialsSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.error).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the guidance follows the field through the fallback`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("me@fastmail.com")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value.quirk).isNull()
|
||||
|
||||
vm.onAddressChanged("https://caldav.icloud.com/")
|
||||
assertThat(vm.onBackWithin()).isTrue()
|
||||
assertThat(vm.state.value.quirk).isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different email typed into the server field becomes the username`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("new.example"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("a@old.example")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onAddressChanged("b@new.example")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("b@new.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mailto address falls back without its prefix`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.example.com"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("mailto:me@example.com")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onAddressChanged("https://dav.example.com/")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("me@example.com")
|
||||
vm.onBackWithin()
|
||||
vm.onBackWithin()
|
||||
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("me@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back from the fallback keeps a re-authentication's username`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.startReauthentication(1L, "https://dav.mailbox.org/caldav/me/", "me@mailbox.org")
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.onBackWithin()).isTrue()
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server address that fails stays on the server field`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405 again",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onAddressChanged("https://mailbox.org/")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER)
|
||||
assertThat(step.input).isEqualTo("https://mailbox.org/")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* Reads SRV and TXT records out of a raw DNS answer.
|
||||
*
|
||||
* The lookup itself is the platform's job — on Android the system resolver,
|
||||
* which follows the current network and honours Private DNS — so this only
|
||||
* parses the wire format it hands back. A malformed answer reads as no records.
|
||||
*/
|
||||
object DnsAnswers {
|
||||
|
||||
const val TYPE_SRV = Type.SRV
|
||||
const val TYPE_TXT = Type.TXT
|
||||
|
||||
fun srv(answer: ByteArray): List<SrvRecord> = records<SRVRecord>(answer)
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
|
||||
fun txt(answer: ByteArray): List<String> = records<TXTRecord>(answer).flatMap { it.strings }
|
||||
|
||||
private inline fun <reified T> records(answer: ByteArray): List<T> = runCatching {
|
||||
Message(answer).getSection(Section.ANSWER).filterIsInstance<T>()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* SRV/TXT lookups over dnsjava.
|
||||
*
|
||||
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
|
||||
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
|
||||
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
|
||||
*
|
||||
* A lookup that fails returns an empty list rather than throwing: DNS being
|
||||
* unavailable means "no SRV record", which is an ordinary and common answer, and
|
||||
* the well-known ladder still has rungs left.
|
||||
*/
|
||||
class DnsJavaResolver : DnsResolver {
|
||||
|
||||
override fun srv(name: String): List<SrvRecord> = runCatching {
|
||||
Lookup(name, Type.SRV).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<SRVRecord>()
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
override fun txt(name: String): List<String> = runCatching {
|
||||
Lookup(name, Type.TXT).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<TXTRecord>()
|
||||
.flatMap { it.strings }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -11,18 +11,20 @@ data class SrvRecord(
|
||||
val target: String,
|
||||
)
|
||||
|
||||
/** The SRV and TXT records published under one name. */
|
||||
data class DnsRecords(val srv: List<SrvRecord>, val txt: List<String>)
|
||||
|
||||
/**
|
||||
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
|
||||
* without a network, and so the resolver can be swapped per platform.
|
||||
*/
|
||||
interface DnsResolver {
|
||||
fun srv(name: String): List<SrvRecord>
|
||||
fun txt(name: String): List<String>
|
||||
/** The SRV and TXT records for [name], looked up together. */
|
||||
fun lookup(name: String): DnsRecords
|
||||
|
||||
/** For the base-URL path, where DNS is never consulted. */
|
||||
object None : DnsResolver {
|
||||
override fun srv(name: String) = emptyList<SrvRecord>()
|
||||
override fun txt(name: String) = emptyList<String>()
|
||||
override fun lookup(name: String) = DnsRecords(emptyList(), emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +114,16 @@ object ServiceDiscovery {
|
||||
val domain = domainOf(trimmed) ?: return emptyList()
|
||||
val candidates = mutableListOf<Candidate>()
|
||||
|
||||
val srv = dns.srv("$SRV_SERVICE.$domain")
|
||||
val records = dns.lookup("$SRV_SERVICE.$domain")
|
||||
val srv = records.srv
|
||||
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
|
||||
// DNS names are case-insensitive and dnsjava returns the wire case, so
|
||||
// DNS names are case-insensitive and answers keep the wire case, so
|
||||
// "Calendar.Google.com." would otherwise walk straight past the guard.
|
||||
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
||||
// ⚠️ RFC 2782 does not require a target to live inside the domain it
|
||||
// was queried for, and this ladder is walked over plain UDP DNS with
|
||||
// no DNSSEC — then walked *again* after a 401, with the
|
||||
// was queried for, and the answer is not DNSSEC-validated (Private
|
||||
// DNS encrypts the hop, it does not authenticate the zone) — and
|
||||
// this ladder is walked *again* after a 401, with the
|
||||
// authenticated client. An out-of-domain target is also one the
|
||||
// credential would never be offered to, since CalDavHttp scopes it
|
||||
// to the typed address's registrable domain, so following it can
|
||||
@@ -131,7 +135,7 @@ object ServiceDiscovery {
|
||||
|
||||
// The TXT path applies to the SRV target, and to the bare domain when
|
||||
// there is no SRV record — that is the GMX/Web.de shape.
|
||||
val txtPath = dns.txt("$SRV_SERVICE.$domain")
|
||||
val txtPath = records.txt
|
||||
.firstNotNullOfOrNull { record ->
|
||||
record.split(' ', ';')
|
||||
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
||||
@@ -205,6 +209,19 @@ object ServiceDiscovery {
|
||||
return input.toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The email address [input] names, without a `mailto:` prefix, or null when
|
||||
* it is a URL or a bare domain — the same split [candidatesFor] makes.
|
||||
*/
|
||||
fun emailOf(input: String): String? {
|
||||
val trimmed = input.trim()
|
||||
if (asBaseUrl(trimmed) != null) return null
|
||||
val email = trimmed.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
return email.takeIf {
|
||||
it.substringBeforeLast('@', "").isNotEmpty() && domainOf(it) != null
|
||||
}
|
||||
}
|
||||
|
||||
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
|
||||
internal fun domainOf(input: String): String? {
|
||||
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
class DnsAnswersTest {
|
||||
|
||||
private val name = Name.fromString("_caldavs._tcp.mailbox.org.")
|
||||
|
||||
private fun answer(type: Int, vararg records: Record): ByteArray {
|
||||
val message = Message.newQuery(Record.newRecord(name, type, DClass.IN))
|
||||
records.forEach { message.addRecord(it, Section.ANSWER) }
|
||||
return message.toWire()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads SRV records`() {
|
||||
val bytes = answer(
|
||||
Type.SRV,
|
||||
SRVRecord(name, DClass.IN, 300, 10, 1, 443, Name.fromString("dav.mailbox.org.")),
|
||||
)
|
||||
assertThat(DnsAnswers.srv(bytes)).containsExactly(SrvRecord(10, 1, 443, "dav.mailbox.org"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads TXT strings`() {
|
||||
val bytes = answer(Type.TXT, TXTRecord(name, DClass.IN, 300, "path=/"))
|
||||
assertThat(DnsAnswers.txt(bytes)).containsExactly("path=/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty or malformed answer has no records`() {
|
||||
assertThat(DnsAnswers.srv(answer(Type.SRV))).isEmpty()
|
||||
assertThat(DnsAnswers.srv(byteArrayOf(1, 2, 3))).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,7 @@ class ServiceDiscoveryTest {
|
||||
val srv: Map<String, List<SrvRecord>> = emptyMap(),
|
||||
val txt: Map<String, List<String>> = emptyMap(),
|
||||
) : DnsResolver {
|
||||
override fun srv(name: String) = srv[name].orEmpty()
|
||||
override fun txt(name: String) = txt[name].orEmpty()
|
||||
override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty())
|
||||
}
|
||||
|
||||
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
||||
@@ -153,6 +152,15 @@ class ServiceDiscoveryTest {
|
||||
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email is told apart from a URL and loses its mailto prefix`() {
|
||||
assertThat(ServiceDiscovery.emailOf("mailto:me@example.com")).isEqualTo("me@example.com")
|
||||
assertThat(ServiceDiscovery.emailOf(" me@example.com ")).isEqualTo("me@example.com")
|
||||
assertThat(ServiceDiscovery.emailOf("https://me@example.com/")).isNull()
|
||||
assertThat(ServiceDiscovery.emailOf("example.com")).isNull()
|
||||
assertThat(ServiceDiscovery.emailOf("@example.com")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `something that is neither an address nor a URL yields nothing`() {
|
||||
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
|
||||
|
||||
+2
-2
@@ -99,8 +99,8 @@ nominated. Data is encrypted in transit.
|
||||
When you type a server address or an email domain, Agendula follows the
|
||||
standard discovery procedure (RFC 6764): a DNS lookup for the `_caldavs._tcp`
|
||||
service record of that domain, then `/.well-known/caldav` on the host. The DNS
|
||||
query goes to whichever resolver your device or network uses, and the requests
|
||||
go to the domain you typed — no directory of servers is consulted and no lookup
|
||||
query goes through Android's own resolver, so it uses your device's Private DNS
|
||||
setting when one is on, and the requests go to the domain you typed — no directory of servers is consulted and no lookup
|
||||
is sent to the developer.
|
||||
|
||||
### Signing in to a Nextcloud
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 68d6432434...e2b1430ffd
@@ -51,10 +51,9 @@ okhttp = "4.12.0"
|
||||
xpp3 = "1.1.6"
|
||||
|
||||
# --- :caldav (our CalDAV protocol layer) -------------------------------------
|
||||
# SRV/TXT lookups for RFC 6764 discovery. Android's resolver exposes no SRV API
|
||||
# below API 29's DnsResolver (which is callback-only and cannot do TXT paths),
|
||||
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
|
||||
# uses. BSD-3 — it goes on the attribution screen with the rest.
|
||||
# Parses the raw SRV/TXT answers for RFC 6764 discovery; the lookup itself goes
|
||||
# through Android's DnsResolver, so it follows the network and Private DNS.
|
||||
# BSD-3 — it goes on the attribution screen with the rest.
|
||||
dnsjava = "3.6.3"
|
||||
# WebDAV-Push delivery. The connector only: DAVx5 also ships the embedded FCM
|
||||
# distributor, which pulls in Play Services and would get us flagged on F-Droid.
|
||||
|
||||
Reference in New Issue
Block a user