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
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-10-25 19:55:03 +01:00
1 parent 44d6038c8b
commit ffc5900db4
21 files changed
+639 -113

No files matched your search

+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