Compare commits

..
Author SHA1 Message Date
makiolaj 4f86dc46bb chore: 1.2.0-beta.2
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Successful in 10m2s
2026-10-25 19:56:20 +01:00
Jean-Luc Makiolaandmakiolaj ffc5900db4 Fix widget class merge, ru date crash, widget padding, form label clipping and SRV discovery (#96)
Fixes four of the five open bug reports, plus #97 (mailbox.org sign-in, reported by email).

- **#92 widgets reverting / "Can't load content"**: R8's horizontal class merging folded `TodayWidget`, `UpNextWidget`, `ListsWidget` and `WeekWidget` into one class. Glance maps providers to receivers by class name, so `TodayWidget().updateAll()` also targeted every Up Next, Lists and Week slot. Any slot without a running session got Today's composition, which is why widgets turned into the Today ring. In a 4x1 slot the ring size goes negative and Glance shows its error layout. A keep rule for `GlanceAppWidget` subclasses stops the merge. I checked the fresh `fullReleaseTest` mapping: all five classes are separate and none has a `$r8$classId`.
- **#70 crash in ru-RU**: fixed in floret-kit (jlmakiola/floret-kit#20). Its year-stripping regex broke `d MMM y 'г'.` into an unterminated quote. The new version also keeps the dot that closes the day-month part (de-DE `05.03.`). That PR is merged; the submodule points at its tip, which is on floret-kit main.
- **#86 widget padding**: when the Tasks widget drops its redundant section label (e.g. the Today list), a spacer above the list now puts the first row 8dp under the header (one `widget_tasks_header_gap` dimen shared with the preview), as in the preview, instead of 2dp.
- **#76 "Starts" clipped**: the label and values in the task form's Starts/Due rows share one line when they fit. Otherwise the values move to their own end-aligned line, using a `FlowRow` rather than a custom layout. The time can wrap under the date, and the clear button always keeps its 40dp.
- **#97 mailbox.org sign-in ("isn't a CalDAV server")**: the SRV/TXT lookup never reached the network. dnsjava was never set up for Android, so every lookup came back empty. mailbox.org's server is only published by SRV (`dav.mailbox.org`), so discovery fell back to `mailbox.org/.well-known/caldav`, which redirects to `auto.mailbox.org` and answers 405. Posteo (SRV-only, port 8443) hit the same dead end. Lookups now go through `android.net.DnsResolver.rawQuery` (minSdk is 29), so they follow the current network and honour Private DNS. SRV and TXT are queried at once under one 10s deadline, and a failure reads as no records. The wait stops as soon as the sign-in is cancelled, and answered lookups are kept for a minute so the signed-in pass does not ask again. `:caldav` only parses the raw answers (`DnsAnswers`, which replaces `DnsJavaResolver`). PRIVACY.md now says so.
  - If an email address still finds no server, the address step switches straight to a server-address field with its own explanation, rather than ending at an error. That also happens when nothing answered for the domain (a mail-only domain), with the error left visible in case it was a typo or no network, and after a sign-in on the email domain. The email (without a `mailto:` prefix) is kept as the username for the sign-in step, and Back returns to the email.

Not included: **#75** (ticking in the 2x2 widget does nothing). The widget is most likely Today, which was one of the merged classes, so this may fix it too. I couldn't show the link from the code, so the issue stays open until it's checked on a device.

Closes #70, closes #76, closes #86, closes #92, closes #97

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/96
2026-10-25 19:55:03 +01:00
22 changed files with 641 additions and 115 deletions
+16
View File
@@ -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
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1020001
versionName = "1.2.0-beta.1"
versionCode = 1020002
versionName = "1.2.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+5
View File
@@ -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
@@ -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"
+1
View File
@@ -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>
@@ -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
View File
@@ -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
+3 -4
View File
@@ -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.