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:
1 parent
44d6038c8b
commit
ffc5900db4
21 files changed
+639
-113
No files matched your search
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
|
||||
|
||||
Reference in new issue
Block a user