Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1590e028c | ||
|
|
08c297f30a | ||
|
|
1d6c2a0d59 | ||
|
|
2a1230c0ca | ||
|
|
cd3b33c959 | ||
|
|
8175d58dcd | ||
|
|
05696b2991 | ||
|
|
23f6156e71 | ||
|
|
c1b65c0c86 | ||
|
|
b5ac279b94 | ||
|
|
8fba3f01a4 | ||
|
|
3b73dd4c53 | ||
|
|
01850a7b74 | ||
|
|
28e646fb31 | ||
|
|
6e3cc4bdbd | ||
|
|
b6185ed53c | ||
|
|
9338c64dc7 | ||
|
|
9a72fb0880 | ||
|
|
24e978efc3 | ||
|
|
2740dd5562 | ||
|
|
8dbfd07d10 | ||
|
|
3e1a84ab7d |
@@ -195,6 +195,15 @@ jobs:
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
# Debug builds aren't minified, so a keep rule that drops a reflectively
|
||||
# created class's constructor only breaks release builds (#1, #75). The
|
||||
# releaseTest twin is R8-shrunk with the release rules and debug-signed.
|
||||
- name: R8 keeps reflective constructors
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: |
|
||||
./gradlew assembleFullReleaseTest
|
||||
python3 scripts/check_r8_keeps.py
|
||||
|
||||
- name: Trivy filesystem scan
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,8 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-10-26
|
||||
|
||||
### Added
|
||||
- A reminder can count from the task's start as well as from its due date, and
|
||||
tasks with only a start date get reminders too. Default reminders can mix
|
||||
@@ -50,6 +52,24 @@ 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).
|
||||
- Ticking off a task in the Tasks and Up next widgets now works in release
|
||||
builds; until now the tap did nothing (#75).
|
||||
- The Tasks widget leaves the same gap under its header as its preview (#86).
|
||||
- The Starts and Due labels in the task form no longer get cut off at large
|
||||
font sizes (#76).
|
||||
- Adding an account by email address finds the server again for providers
|
||||
that publish it in DNS, such as mailbox.org and Posteo. The DNS lookup never
|
||||
reached the network, so they ended in "isn't a CalDAV server". It now goes
|
||||
through Android's resolver and respects Private DNS (#97).
|
||||
- When the server can't be found from your email address, account setup asks
|
||||
for the server address straight away and keeps your email as the username,
|
||||
instead of stopping at an error (#97).
|
||||
|
||||
## [1.1.0] - 2026-10-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1020001
|
||||
versionName = "1.2.0-beta.1"
|
||||
versionCode = 1020099
|
||||
versionName = "1.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||
# as "Failed to create an instance of ...". This first bit us through a
|
||||
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
||||
# issue #1); Glance is gone and Room is now our own task store, so the rule
|
||||
# matters more, not less — TasksDatabase is built on the first store read.
|
||||
# issue #1); Room is now also our own task store, so the rule matters more,
|
||||
# not less — TasksDatabase is built on the first store read.
|
||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||
|
||||
# Compose Compiler may keep its own; defaults are fine
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.caldav.AppPassword
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.DnsJavaResolver
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -75,7 +75,8 @@ class OkHttpCalDavGateway @Inject constructor(
|
||||
val client = credentials?.let {
|
||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
||||
} ?: CalDavHttp.anonymous(userAgent)
|
||||
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
|
||||
val job = coroutineContext.job
|
||||
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target)
|
||||
}
|
||||
|
||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.net.DnsResolver as PlatformDnsResolver
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Looper
|
||||
import de.jeanlucmakiola.caldav.DnsAnswers
|
||||
import de.jeanlucmakiola.caldav.DnsRecords
|
||||
import de.jeanlucmakiola.caldav.DnsResolver
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* SRV/TXT lookups through the system resolver, so discovery uses the current
|
||||
* network's DNS and honours Private DNS rather than sending plain queries of
|
||||
* its own. A failed or timed-out lookup reads as no records.
|
||||
*
|
||||
* The platform delivers answers through the main looper, so this must run on a
|
||||
* worker thread; waiting on the main one would deadlock until the timeout.
|
||||
* The wait gives up as soon as [isCancelled] says so, and answered lookups are
|
||||
* kept briefly so the authenticated walk does not ask again.
|
||||
*/
|
||||
class SystemDnsResolver(
|
||||
private val isCancelled: () -> Boolean = { false },
|
||||
) : DnsResolver {
|
||||
|
||||
override fun lookup(name: String): DnsRecords {
|
||||
check(!Looper.getMainLooper().isCurrentThread) { "DNS lookup on the main thread" }
|
||||
val now = System.nanoTime()
|
||||
cache[name]?.takeIf { now - it.at < CACHE_NANOS }?.let { return it.records }
|
||||
val srv = Query(name, DnsAnswers.TYPE_SRV)
|
||||
val txt = Query(name, DnsAnswers.TYPE_TXT)
|
||||
val answered = try {
|
||||
val deadline = now + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
|
||||
srv.await(deadline, isCancelled) && txt.await(deadline, isCancelled)
|
||||
} finally {
|
||||
srv.cancel()
|
||||
txt.cancel()
|
||||
}
|
||||
val records = DnsRecords(
|
||||
srv.answer()?.let(DnsAnswers::srv).orEmpty(),
|
||||
txt.answer()?.let(DnsAnswers::txt).orEmpty(),
|
||||
)
|
||||
if (answered) cache[name] = Cached(now, records)
|
||||
return records
|
||||
}
|
||||
|
||||
private class Cached(val at: Long, val records: DnsRecords)
|
||||
|
||||
private class Query(name: String, type: Int) {
|
||||
private val done = CountDownLatch(1)
|
||||
private val signal = CancellationSignal()
|
||||
private val result = AtomicReference<ByteArray?>()
|
||||
|
||||
init {
|
||||
try {
|
||||
PlatformDnsResolver.getInstance().rawQuery(
|
||||
null,
|
||||
name,
|
||||
PlatformDnsResolver.CLASS_IN,
|
||||
type,
|
||||
PlatformDnsResolver.FLAG_EMPTY,
|
||||
Runnable::run,
|
||||
signal,
|
||||
object : PlatformDnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
result.set(answer)
|
||||
done.countDown()
|
||||
}
|
||||
|
||||
override fun onError(error: PlatformDnsResolver.DnsException) {
|
||||
done.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: RuntimeException) {
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the query finished before [deadline] and before [isCancelled]. */
|
||||
fun await(deadline: Long, isCancelled: () -> Boolean): Boolean {
|
||||
while (!isCancelled()) {
|
||||
val left = deadline - System.nanoTime()
|
||||
if (left <= 0) return false
|
||||
if (done.await(minOf(left, SLICE_NANOS), TimeUnit.NANOSECONDS)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun cancel() = signal.cancel()
|
||||
|
||||
fun answer(): ByteArray? = result.get()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 10L
|
||||
val SLICE_NANOS = TimeUnit.MILLISECONDS.toNanos(100)
|
||||
val CACHE_NANOS = TimeUnit.MINUTES.toNanos(1)
|
||||
val cache = ConcurrentHashMap<String, Cached>()
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,9 @@ private fun StepTitle(step: AddAccountStep) {
|
||||
R.string.add_account_provider_title to R.string.add_account_provider_body
|
||||
is AddAccountStep.PrepareAccess ->
|
||||
step.quirk.setupTitle to R.string.add_account_setup_body
|
||||
is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) {
|
||||
is AddAccountStep.EnterAddress -> if (step.manualServer) {
|
||||
R.string.add_account_server_title to R.string.add_account_server_fallback_body
|
||||
} else if (step.asksForEmail) {
|
||||
R.string.add_account_email_title to R.string.add_account_email_body
|
||||
} else {
|
||||
R.string.add_account_server_title to R.string.add_account_server_body
|
||||
|
||||
+83
-19
@@ -77,7 +77,12 @@ sealed interface AddAccountStep {
|
||||
val input: String = "",
|
||||
/** A cause, not a message — the screen owns the wording. */
|
||||
val error: CalDavDiscovery.Outcome.Cause? = null,
|
||||
) : AddAccountStep
|
||||
/** The email address found no server, so a hosted choice asks for its URL instead. */
|
||||
val manualServer: Boolean = false,
|
||||
) : AddAccountStep {
|
||||
/** Whether the field takes an email address rather than a server URL. */
|
||||
val asksForEmail: Boolean get() = choice.provider?.hosted == true && !manualServer
|
||||
}
|
||||
|
||||
data class Working(val message: AddAccountMessage.Progress) : AddAccountStep
|
||||
|
||||
@@ -279,6 +284,11 @@ class AddAccountViewModel @Inject constructor(
|
||||
get() = handle[KEY_REAUTH_ACCOUNT]
|
||||
set(value) { handle[KEY_REAUTH_ACCOUNT] = value }
|
||||
|
||||
/** The email that found no server; Back returns to it from the server field. */
|
||||
private var fallbackEmail: String?
|
||||
get() = handle[KEY_FALLBACK_EMAIL]
|
||||
set(value) { handle[KEY_FALLBACK_EMAIL] = value }
|
||||
|
||||
/** The username the credentials step starts with on a re-authentication. */
|
||||
private var suggestedUsername: String
|
||||
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
|
||||
@@ -370,6 +380,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
// one is not an answer to this one.
|
||||
if (picked != choice) addressInput = ""
|
||||
choice = picked
|
||||
clearFallback()
|
||||
val quirk = picked.provider?.let(ServerQuirk::forProvider)
|
||||
val setup = quirk?.hasSetupSteps == true
|
||||
updateState {
|
||||
@@ -430,11 +441,10 @@ class AddAccountViewModel @Inject constructor(
|
||||
addressInput = value
|
||||
updateState {
|
||||
it.copy(
|
||||
step = AddAccountStep.EnterAddress(picked, value),
|
||||
step = addressStep(value),
|
||||
// The service's own rule outranks the one the typed host implies:
|
||||
// it was chosen deliberately, and it is known before a key is hit.
|
||||
quirk = picked.provider?.let(ServerQuirk::forProvider)
|
||||
?: ServerQuirk.forInput(value),
|
||||
quirk = quirkFor(value),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -505,13 +515,47 @@ class AddAccountViewModel @Inject constructor(
|
||||
// would make it permanently unreachable.
|
||||
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
|
||||
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email address whose domain has no CalDAV server goes straight to the
|
||||
* server field rather than an error with nowhere to go; the email stays as
|
||||
* the username. A domain nothing answered for may also be a typo or no
|
||||
* network, so that message stays visible on the server field. Any other
|
||||
* failure keeps its own message on the email field.
|
||||
*/
|
||||
private fun backToAddressOrServer(cause: CalDavDiscovery.Outcome.Cause) {
|
||||
val email = ServiceDiscovery.emailOf(typedInput)
|
||||
if (fallbackEmail != null || email == null || cause !in NO_SERVER_FOUND) {
|
||||
return backToAddress(cause)
|
||||
}
|
||||
fallbackEmail = email
|
||||
addressInput = ""
|
||||
updateState {
|
||||
it.copy(
|
||||
step = addressStep("", cause.takeIf { it == CalDavDiscovery.Outcome.Cause.UNREACHABLE }),
|
||||
quirk = quirkFor(""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearFallback() {
|
||||
fallbackEmail = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The username the credentials step starts with: a re-authentication's, else
|
||||
* the email that fell back to the server field — or one typed into it.
|
||||
*/
|
||||
private fun prefilledUsername(): String = suggestedUsername.ifBlank {
|
||||
fallbackEmail?.let { ServiceDiscovery.emailOf(typedInput) ?: it }.orEmpty()
|
||||
}
|
||||
|
||||
/** Step 3a → re-run discovery with the password the user typed. */
|
||||
fun onCredentialsSubmitted() {
|
||||
val step = current.step as? AddAccountStep.EnterCredentials ?: return
|
||||
@@ -549,8 +593,8 @@ class AddAccountViewModel @Inject constructor(
|
||||
|
||||
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
||||
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,7 +621,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
updateState {
|
||||
it.copy(
|
||||
step = AddAccountStep.EnterCredentials(
|
||||
username = suggestedUsername,
|
||||
username = prefilledUsername(),
|
||||
error = quirkHint(),
|
||||
),
|
||||
)
|
||||
@@ -803,7 +847,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
pollJob?.cancel()
|
||||
updateState {
|
||||
it.copy(
|
||||
step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null),
|
||||
step = AddAccountStep.EnterCredentials(username = prefilledUsername(), error = null),
|
||||
openInBrowser = null,
|
||||
)
|
||||
}
|
||||
@@ -916,7 +960,17 @@ class AddAccountViewModel @Inject constructor(
|
||||
// Back into the errand when there was one, so the steps can be re-read
|
||||
// without starting the flow again — that is the screen people return to.
|
||||
is AddAccountStep.EnterAddress -> {
|
||||
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
|
||||
val email = fallbackEmail
|
||||
if (email != null) {
|
||||
clearFallback()
|
||||
addressInput = email
|
||||
typedInput = email
|
||||
updateState {
|
||||
it.copy(step = addressStep(email), fatal = null, quirk = quirkFor(email))
|
||||
}
|
||||
} else {
|
||||
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -990,6 +1044,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
hostsNeedingAuth = emptyList()
|
||||
reauthAccountId = null
|
||||
suggestedUsername = ""
|
||||
fallbackEmail = null
|
||||
current = AddAccountUiState()
|
||||
}
|
||||
|
||||
@@ -1119,8 +1174,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
reauthenticating = reauthAccountId != null,
|
||||
// Derived rather than saved: it is a pure function of the choice and the
|
||||
// address, both of which are.
|
||||
quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
||||
?: ServerQuirk.forInput(addressInput),
|
||||
quirk = quirkFor(addressInput),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1148,8 +1202,8 @@ class AddAccountViewModel @Inject constructor(
|
||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||
when (val outcome = gateway.discover(target, credentials)) {
|
||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||
// The credential we were holding is no longer accepted, which on
|
||||
// this path is indistinguishable from never having had one.
|
||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||
@@ -1250,6 +1304,7 @@ class AddAccountViewModel @Inject constructor(
|
||||
choice = choice ?: ProviderChoice.OtherServer,
|
||||
input = input,
|
||||
error = error,
|
||||
manualServer = fallbackEmail != null,
|
||||
)
|
||||
|
||||
/** What was actually set up, named the way the accounts list will name it. */
|
||||
@@ -1286,12 +1341,13 @@ class AddAccountViewModel @Inject constructor(
|
||||
updateState { it.copy(step = transform(step)) }
|
||||
}
|
||||
|
||||
/** The chosen service's quirk, else the one the typed host implies. */
|
||||
private fun quirkFor(input: String): ServerQuirk? =
|
||||
choice?.provider?.let(ServerQuirk::forProvider) ?: ServerQuirk.forInput(input)
|
||||
|
||||
/** The provider-specific reason a correct-looking password gets rejected. */
|
||||
private fun quirkHint(): AddAccountMessage? =
|
||||
when (
|
||||
val quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
||||
?: ServerQuirk.forInput(typedInput)
|
||||
) {
|
||||
when (val quirk = quirkFor(typedInput)) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD,
|
||||
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
|
||||
-> AddAccountMessage.Quirk(quirk)
|
||||
@@ -1377,6 +1433,14 @@ class AddAccountViewModel @Inject constructor(
|
||||
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
|
||||
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
||||
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
||||
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
|
||||
|
||||
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
|
||||
val NO_SERVER_FOUND = setOf(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* What to type for a server, from the principal an account stored.
|
||||
|
||||
@@ -20,7 +20,7 @@ import de.jeanlucmakiola.agendula.R
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
|
||||
val hosted = step.choice.provider?.hosted == true
|
||||
val hosted = step.asksForEmail
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
FieldRow(
|
||||
label = if (hosted) {
|
||||
@@ -30,7 +30,7 @@ internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccoun
|
||||
},
|
||||
value = step.input,
|
||||
onValueChange = viewModel::onAddressChanged,
|
||||
placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" }
|
||||
placeholder = step.choice.provider?.primaryDomain?.takeIf { hosted }?.let { "you@$it" }
|
||||
?: stringResource(R.string.add_account_server_hint),
|
||||
error = step.error?.let { stringResource(it.message) },
|
||||
// An email address is still typed with the URI keyboard: it is the
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -883,6 +884,7 @@ private fun InlineField(
|
||||
* affordance appears once a value is set. Tappable values read as links
|
||||
* (primary); an error flips them to the error colour.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ScheduleRow(
|
||||
label: String,
|
||||
@@ -893,43 +895,76 @@ private fun ScheduleRow(
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (value == null) {
|
||||
val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp)
|
||||
LabelledValueLayout(
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_set),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = value.formatDate(allDay),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!allDay) {
|
||||
Text(
|
||||
text = value.formatTime(LocalUse24HourFormat.current),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Rounded.Clear,
|
||||
contentDescription = stringResource(R.string.edit_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
value = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (value == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.edit_set),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = valueModifier,
|
||||
)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
Text(
|
||||
text = value.formatDate(allDay),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = valueModifier,
|
||||
)
|
||||
if (!allDay) {
|
||||
Text(
|
||||
text = value.formatTime(LocalUse24HourFormat.current),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = valueModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Rounded.Clear,
|
||||
contentDescription = stringResource(R.string.edit_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [label] at the start and [value] at the end of one line when both fit, else
|
||||
* [value] on its own line below, still end-aligned — so large font scales wrap
|
||||
* rather than clip either side (#76).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LabelledValueLayout(label: @Composable () -> Unit, value: @Composable () -> Unit) {
|
||||
FlowRow(
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
label()
|
||||
// Its whole width as its minimum, so the value wraps as one piece.
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(IntrinsicSize.Max)) { value() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,12 +183,15 @@ private fun TaskWidgetBody(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
TaskWidgetHeader(title, filter, ready?.singleListId)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
when (data) {
|
||||
null -> Unit
|
||||
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
TaskWidgetData.Unavailable -> {
|
||||
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
||||
WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
}
|
||||
is TaskWidgetData.Ready ->
|
||||
if (data.rows.isEmpty()) {
|
||||
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
|
||||
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||
} else {
|
||||
val allSections = widgetSections(
|
||||
@@ -213,6 +216,12 @@ private fun TaskWidgetBody(
|
||||
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
|
||||
onlyHeader.key == "due:${redundantDueKind.name}"
|
||||
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
|
||||
val leadingGap = if (sections.firstOrNull() is WidgetSection.TaskItem) {
|
||||
R.dimen.widget_tasks_header_gap
|
||||
} else {
|
||||
R.dimen.widget_tasks_header_gap_tight
|
||||
}
|
||||
Spacer(GlanceModifier.height(leadingGap))
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(sections.size) { index ->
|
||||
when (val section = sections[index]) {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginTop="@dimen/widget_tasks_header_gap"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
|
||||
@@ -718,6 +718,7 @@
|
||||
<string name="add_account_summary_body">Agendula will keep these lists in sync from now on. You can change what syncs in Settings.</string>
|
||||
<string name="add_account_summary_done">Done</string>
|
||||
<string name="add_account_server_title">Where are your tasks?</string>
|
||||
<string name="add_account_server_fallback_body">Agendula couldn\u2019t find the server from your email address. Enter the CalDAV server address your provider gives for calendars instead.</string>
|
||||
<string name="add_account_server_body">Enter the address of your CalDAV server. An email address works too, if the server publishes itself under its domain.</string>
|
||||
<string name="add_account_credentials_title">Sign in</string>
|
||||
<string name="add_account_credentials_body">Use an app password if your provider offers one \u2014 it can be revoked without changing your account password.</string>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Header to first task row in the Tasks widget; shared with its picker preview. -->
|
||||
<dimen name="widget_tasks_header_gap">8dp</dimen>
|
||||
<!-- Header to anything that brings its own top spacing (a section label, a message). -->
|
||||
<dimen name="widget_tasks_header_gap_tight">2dp</dimen>
|
||||
</resources>
|
||||
+220
@@ -218,6 +218,226 @@ class AddAccountViewModelTest {
|
||||
// The input survives, so the user can correct it rather than retype it.
|
||||
assertThat(step.input).isEqualTo("https://example.com/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email that finds no server asks for the server address instead`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405 on mailbox.org",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.input).isEmpty()
|
||||
assertThat(step.error).isNull()
|
||||
|
||||
vm.onAddressChanged("https://dav.mailbox.org/")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
// The email the user already gave is the username, not retyped.
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back from the server fallback returns to the email`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405 on mailbox.org",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.onBackWithin()).isTrue()
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isFalse()
|
||||
assertThat(step.input).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure that is not a missing server keeps its error on the email`() = runTest(dispatcher) {
|
||||
for (cause in listOf(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS,
|
||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
|
||||
)) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(cause, "$cause")
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isFalse()
|
||||
assertThat(step.error).isEqualTo(cause)
|
||||
assertThat(step.input).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email nothing answered for asks for the server and keeps the error`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
|
||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
|
||||
"no route",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("me@mail-only.example")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.input).isEmpty()
|
||||
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email that asked for a password first still reaches the server field`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("example.com"))
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("me@example.com")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onUsernameChanged("me@example.com")
|
||||
vm.onPasswordChanged("secret")
|
||||
vm.onCredentialsSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.error).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the guidance follows the field through the fallback`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("me@fastmail.com")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value.quirk).isNull()
|
||||
|
||||
vm.onAddressChanged("https://caldav.icloud.com/")
|
||||
assertThat(vm.onBackWithin()).isTrue()
|
||||
assertThat(vm.state.value.quirk).isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different email typed into the server field becomes the username`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("new.example"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("a@old.example")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onAddressChanged("b@new.example")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("b@new.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mailto address falls back without its prefix`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.example.com"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.OtherServer)
|
||||
vm.onAddressChanged("mailto:me@example.com")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onAddressChanged("https://dav.example.com/")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("me@example.com")
|
||||
vm.onBackWithin()
|
||||
vm.onBackWithin()
|
||||
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("me@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back from the fallback keeps a re-authentication's username`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org"))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.startReauthentication(1L, "https://dav.mailbox.org/caldav/me/", "me@mailbox.org")
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.onBackWithin()).isTrue()
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
|
||||
assertThat(credentials.username).isEqualTo("me@mailbox.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server address that fails stays on the server field`() = runTest(dispatcher) {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405",
|
||||
)
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"405 again",
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
|
||||
vm.onAddressChanged("me@mailbox.org")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
vm.onAddressChanged("https://mailbox.org/")
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
val step = vm.state.value.step as AddAccountStep.EnterAddress
|
||||
assertThat(step.manualServer).isTrue()
|
||||
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER)
|
||||
assertThat(step.input).isEqualTo("https://mailbox.org/")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* Reads SRV and TXT records out of a raw DNS answer.
|
||||
*
|
||||
* The lookup itself is the platform's job — on Android the system resolver,
|
||||
* which follows the current network and honours Private DNS — so this only
|
||||
* parses the wire format it hands back. A malformed answer reads as no records.
|
||||
*/
|
||||
object DnsAnswers {
|
||||
|
||||
const val TYPE_SRV = Type.SRV
|
||||
const val TYPE_TXT = Type.TXT
|
||||
|
||||
fun srv(answer: ByteArray): List<SrvRecord> = records<SRVRecord>(answer)
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
|
||||
fun txt(answer: ByteArray): List<String> = records<TXTRecord>(answer).flatMap { it.strings }
|
||||
|
||||
private inline fun <reified T> records(answer: ByteArray): List<T> = runCatching {
|
||||
Message(answer).getSection(Section.ANSWER).filterIsInstance<T>()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* SRV/TXT lookups over dnsjava.
|
||||
*
|
||||
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
|
||||
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
|
||||
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
|
||||
*
|
||||
* A lookup that fails returns an empty list rather than throwing: DNS being
|
||||
* unavailable means "no SRV record", which is an ordinary and common answer, and
|
||||
* the well-known ladder still has rungs left.
|
||||
*/
|
||||
class DnsJavaResolver : DnsResolver {
|
||||
|
||||
override fun srv(name: String): List<SrvRecord> = runCatching {
|
||||
Lookup(name, Type.SRV).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<SRVRecord>()
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
override fun txt(name: String): List<String> = runCatching {
|
||||
Lookup(name, Type.TXT).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<TXTRecord>()
|
||||
.flatMap { it.strings }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -11,18 +11,20 @@ data class SrvRecord(
|
||||
val target: String,
|
||||
)
|
||||
|
||||
/** The SRV and TXT records published under one name. */
|
||||
data class DnsRecords(val srv: List<SrvRecord>, val txt: List<String>)
|
||||
|
||||
/**
|
||||
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
|
||||
* without a network, and so the resolver can be swapped per platform.
|
||||
*/
|
||||
interface DnsResolver {
|
||||
fun srv(name: String): List<SrvRecord>
|
||||
fun txt(name: String): List<String>
|
||||
/** The SRV and TXT records for [name], looked up together. */
|
||||
fun lookup(name: String): DnsRecords
|
||||
|
||||
/** For the base-URL path, where DNS is never consulted. */
|
||||
object None : DnsResolver {
|
||||
override fun srv(name: String) = emptyList<SrvRecord>()
|
||||
override fun txt(name: String) = emptyList<String>()
|
||||
override fun lookup(name: String) = DnsRecords(emptyList(), emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +114,16 @@ object ServiceDiscovery {
|
||||
val domain = domainOf(trimmed) ?: return emptyList()
|
||||
val candidates = mutableListOf<Candidate>()
|
||||
|
||||
val srv = dns.srv("$SRV_SERVICE.$domain")
|
||||
val records = dns.lookup("$SRV_SERVICE.$domain")
|
||||
val srv = records.srv
|
||||
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
|
||||
// DNS names are case-insensitive and dnsjava returns the wire case, so
|
||||
// DNS names are case-insensitive and answers keep the wire case, so
|
||||
// "Calendar.Google.com." would otherwise walk straight past the guard.
|
||||
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
||||
// ⚠️ RFC 2782 does not require a target to live inside the domain it
|
||||
// was queried for, and this ladder is walked over plain UDP DNS with
|
||||
// no DNSSEC — then walked *again* after a 401, with the
|
||||
// was queried for, and the answer is not DNSSEC-validated (Private
|
||||
// DNS encrypts the hop, it does not authenticate the zone) — and
|
||||
// this ladder is walked *again* after a 401, with the
|
||||
// authenticated client. An out-of-domain target is also one the
|
||||
// credential would never be offered to, since CalDavHttp scopes it
|
||||
// to the typed address's registrable domain, so following it can
|
||||
@@ -131,7 +135,7 @@ object ServiceDiscovery {
|
||||
|
||||
// The TXT path applies to the SRV target, and to the bare domain when
|
||||
// there is no SRV record — that is the GMX/Web.de shape.
|
||||
val txtPath = dns.txt("$SRV_SERVICE.$domain")
|
||||
val txtPath = records.txt
|
||||
.firstNotNullOfOrNull { record ->
|
||||
record.split(' ', ';')
|
||||
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
||||
@@ -205,6 +209,19 @@ object ServiceDiscovery {
|
||||
return input.toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The email address [input] names, without a `mailto:` prefix, or null when
|
||||
* it is a URL or a bare domain — the same split [candidatesFor] makes.
|
||||
*/
|
||||
fun emailOf(input: String): String? {
|
||||
val trimmed = input.trim()
|
||||
if (asBaseUrl(trimmed) != null) return null
|
||||
val email = trimmed.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
return email.takeIf {
|
||||
it.substringBeforeLast('@', "").isNotEmpty() && domainOf(it) != null
|
||||
}
|
||||
}
|
||||
|
||||
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
|
||||
internal fun domainOf(input: String): String? {
|
||||
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
class DnsAnswersTest {
|
||||
|
||||
private val name = Name.fromString("_caldavs._tcp.mailbox.org.")
|
||||
|
||||
private fun answer(type: Int, vararg records: Record): ByteArray {
|
||||
val message = Message.newQuery(Record.newRecord(name, type, DClass.IN))
|
||||
records.forEach { message.addRecord(it, Section.ANSWER) }
|
||||
return message.toWire()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads SRV records`() {
|
||||
val bytes = answer(
|
||||
Type.SRV,
|
||||
SRVRecord(name, DClass.IN, 300, 10, 1, 443, Name.fromString("dav.mailbox.org.")),
|
||||
)
|
||||
assertThat(DnsAnswers.srv(bytes)).containsExactly(SrvRecord(10, 1, 443, "dav.mailbox.org"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads TXT strings`() {
|
||||
val bytes = answer(Type.TXT, TXTRecord(name, DClass.IN, 300, "path=/"))
|
||||
assertThat(DnsAnswers.txt(bytes)).containsExactly("path=/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty or malformed answer has no records`() {
|
||||
assertThat(DnsAnswers.srv(answer(Type.SRV))).isEmpty()
|
||||
assertThat(DnsAnswers.srv(byteArrayOf(1, 2, 3))).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,7 @@ class ServiceDiscoveryTest {
|
||||
val srv: Map<String, List<SrvRecord>> = emptyMap(),
|
||||
val txt: Map<String, List<String>> = emptyMap(),
|
||||
) : DnsResolver {
|
||||
override fun srv(name: String) = srv[name].orEmpty()
|
||||
override fun txt(name: String) = txt[name].orEmpty()
|
||||
override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty())
|
||||
}
|
||||
|
||||
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
||||
@@ -153,6 +152,15 @@ class ServiceDiscoveryTest {
|
||||
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email is told apart from a URL and loses its mailto prefix`() {
|
||||
assertThat(ServiceDiscovery.emailOf("mailto:me@example.com")).isEqualTo("me@example.com")
|
||||
assertThat(ServiceDiscovery.emailOf(" me@example.com ")).isEqualTo("me@example.com")
|
||||
assertThat(ServiceDiscovery.emailOf("https://me@example.com/")).isNull()
|
||||
assertThat(ServiceDiscovery.emailOf("example.com")).isNull()
|
||||
assertThat(ServiceDiscovery.emailOf("@example.com")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `something that is neither an address nor a URL yields nothing`() {
|
||||
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
|
||||
|
||||
+2
-2
@@ -99,8 +99,8 @@ nominated. Data is encrypted in transit.
|
||||
When you type a server address or an email domain, Agendula follows the
|
||||
standard discovery procedure (RFC 6764): a DNS lookup for the `_caldavs._tcp`
|
||||
service record of that domain, then `/.well-known/caldav` on the host. The DNS
|
||||
query goes to whichever resolver your device or network uses, and the requests
|
||||
go to the domain you typed — no directory of servers is consulted and no lookup
|
||||
query goes through Android's own resolver, so it uses your device's Private DNS
|
||||
setting when one is on, and the requests go to the domain you typed — no directory of servers is consulted and no lookup
|
||||
is sent to the developer.
|
||||
|
||||
### Signing in to a Nextcloud
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
### جديد
|
||||
- يمكن أن يُحسب التذكير من بدء المهمة أو من موعد الاستحقاق.
|
||||
- يمكن لمهام اليوم الكامل أن تحمل تذكيرات في أوقات محددة.
|
||||
- يمكن للتطبيقات الأخرى قراءة مهامك المحلية وتعديلها عبر OpenTasks، بإذنك.
|
||||
|
||||
### تغييرات
|
||||
- تُزامَن التذكيرات كـ VALARM، فترنّ أيضًا في تذكيرات Apple وThunderbird وtasks.org.
|
||||
|
||||
### إصلاحات
|
||||
- تعمل الأدوات مجددًا بعد إنجاز المهام.
|
||||
- عاد تسجيل الدخول بالبريد الإلكتروني يعمل لدى mailbox.org وPosteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
Agendula تطبيق مهام حديث ومفتوح المصدر لنظام Android.
|
||||
|
||||
زامن مهامك عبر CalDAV: أضف حساب Nextcloud أو Radicale أو Baïkal أو أي حساب CalDAV آخر، واختر قوائم المهام التي تريد مزامنتها. أو احتفظ بكل شيء على الهاتف، فـ Agendula يخزّن مهامك بنفسه، دون حساب ودون تثبيت أي شيء آخر.
|
||||
|
||||
هل تستخدم OpenTasks أو tasks.org؟ اربط Agendula بذلك التطبيق لتظهر المهام التي يزامنها DAVx5 أو ما يشبهه كما هي، وانسخها إلى تخزين Agendula الخاص متى شئت من الإعدادات ← التخزين.
|
||||
|
||||
ما يقدمه: قوائم تديرها داخل التطبيق، تواريخ الاستحقاق والبدء، مهام اليوم الكامل، مهام فرعية، أولويات، نسبة الإنجاز، مهام متكررة يمكنك تعديل موعد واحد منها، عدة تذكيرات لكل مهمة مع الغفوة، استيراد iCalendar وتصديره، أداة للشاشة الرئيسية، اختصارات المشغّل، وبلاطة في الإعدادات السريعة.
|
||||
|
||||
التصميم Material 3 Expressive حقيقي في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة.
|
||||
|
||||
الخصوصية: لا قياس عن بُعد، ولا تحليلات، ولا إعلانات. لا يتصل Agendula بالإنترنت إلا للمزامنة مع حسابات CalDAV التي تضيفها. ودون حساب، لا تغادر مهامك الجهاز إلا إذا صدّرتها.
|
||||
@@ -0,0 +1 @@
|
||||
تطبيق مهام بتصميم Material 3 Expressive مع مزامنة CalDAV.
|
||||
@@ -0,0 +1 @@
|
||||
Agendula: Tasks
|
||||
@@ -0,0 +1,11 @@
|
||||
### Nové
|
||||
- Připomínky se mohou počítat od začátku úkolu i od termínu.
|
||||
- Celodenní úkoly mohou mít připomínky v pevný čas.
|
||||
- Jiné aplikace mohou s vaším svolením číst a upravovat místní úkoly přes OpenTasks.
|
||||
|
||||
### Změny
|
||||
- Připomínky se synchronizují jako VALARM a zvoní i v Připomínkách Apple, Thunderbirdu a tasks.org.
|
||||
|
||||
### Opravy
|
||||
- Widgety opět fungují po odškrtnutí úkolu.
|
||||
- Přihlášení e-mailem u mailbox.org a Posteo opět funguje.
|
||||
@@ -0,0 +1,11 @@
|
||||
Agendula je moderní open-source aplikace na úkoly pro Android.
|
||||
|
||||
Synchronizujte úkoly přes CalDAV: přidejte účet Nextcloud, Radicale, Baïkal nebo jiný CalDAV účet a vyberte, které seznamy úkolů se mají synchronizovat. Nebo nechte vše v telefonu. Agendula si úkoly ukládá sama, bez účtu a bez instalace čehokoli dalšího.
|
||||
|
||||
Už používáte OpenTasks nebo tasks.org? Nasměrujte Agendulu na tuto aplikaci, aby se úkoly synchronizované přes DAVx5 a podobně zobrazily tak, jak jsou, a kdykoli si je zkopírujte do vlastního úložiště Agenduly v Nastavení → Úložiště.
|
||||
|
||||
Co umí: seznamy spravované přímo v aplikaci, termíny a data začátku, celodenní úkoly, podúkoly, priority, průběh, opakované úkoly s možností upravit jediný výskyt, více připomínek na úkol s odložením, import a export iCalendar, widget na plochu, zkratky spouštěče a dlaždici Rychlého nastavení.
|
||||
|
||||
Design je skrz naskrz Material 3 Expressive, s dynamickými barvami a expresivními animacemi a tvary.
|
||||
|
||||
Soukromí: žádná telemetrie, žádná analytika, žádné reklamy. Agendula se připojuje k internetu jen kvůli synchronizaci s CalDAV účty, které sami přidáte. Bez účtu vaše úkoly zařízení neopustí, pokud je sami neexportujete.
|
||||
@@ -0,0 +1 @@
|
||||
Úkolníček v Material 3 Expressive se synchronizací CalDAV.
|
||||
@@ -0,0 +1 @@
|
||||
Agendula: Tasks
|
||||
@@ -0,0 +1,11 @@
|
||||
### Neu
|
||||
- Erinnerungen können ab dem Start oder ab der Fälligkeit zählen.
|
||||
- Ganztägige Aufgaben können Erinnerungen zu festen Uhrzeiten haben.
|
||||
- Andere Apps können lokale Aufgaben über OpenTasks lesen und bearbeiten, wenn du es erlaubst.
|
||||
|
||||
### Geändert
|
||||
- Erinnerungen werden als VALARM synchronisiert und klingeln auch in Apple Erinnerungen, Thunderbird und tasks.org.
|
||||
|
||||
### Behoben
|
||||
- Widgets funktionieren wieder nach dem Abhaken.
|
||||
- Die Anmeldung per E-Mail klappt wieder bei mailbox.org und Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
### Added
|
||||
- Reminders can count from a task's start as well as its due date.
|
||||
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
|
||||
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
|
||||
|
||||
### Changed
|
||||
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
|
||||
|
||||
### Fixed
|
||||
- Widgets work again after ticking off tasks.
|
||||
- Signing in by email works again for mailbox.org and Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
### Added
|
||||
- Reminders can count from a task's start as well as its due date.
|
||||
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
|
||||
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
|
||||
|
||||
### Changed
|
||||
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
|
||||
|
||||
### Fixed
|
||||
- Widgets work again after ticking off tasks.
|
||||
- Signing in by email works again for mailbox.org and Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
### Nouveautés
|
||||
- Un rappel peut partir du début ou de l'échéance d'une tâche.
|
||||
- Rappels à heure fixe pour les tâches sur la journée.
|
||||
- Avec votre accord, d'autres applis peuvent lire et modifier vos tâches locales via OpenTasks.
|
||||
|
||||
### Modifications
|
||||
- Les rappels se synchronisent en VALARM et sonnent aussi dans Rappels d'Apple, Thunderbird et tasks.org.
|
||||
|
||||
### Corrections
|
||||
- Les widgets marchent de nouveau après une tâche cochée.
|
||||
- La connexion par e-mail marche de nouveau avec mailbox.org et Posteo.
|
||||
@@ -0,0 +1,11 @@
|
||||
Agendula est une appli de tâches moderne et open source pour Android.
|
||||
|
||||
Synchronisez vos tâches via CalDAV : ajoutez un compte Nextcloud, Radicale, Baïkal ou tout autre compte CalDAV et choisissez les listes de tâches à synchroniser. Ou gardez tout sur le téléphone : Agendula stocke vos tâches lui-même, sans compte et sans rien d'autre à installer.
|
||||
|
||||
Vous utilisez déjà OpenTasks ou tasks.org ? Reliez Agendula à cette appli pour que les tâches synchronisées par DAVx5 ou similaire s'affichent telles quelles, et copiez-les quand vous voulez dans le stockage propre d'Agendula, sous Réglages → Stockage.
|
||||
|
||||
Ce qu'il fait : listes gérées dans l'appli, dates d'échéance et de début, tâches sur toute la journée, sous-tâches, priorités, avancement, tâches récurrentes dont vous pouvez modifier une seule occurrence, plusieurs rappels par tâche avec report, import et export iCalendar, un widget d'écran d'accueil, des raccourcis de lanceur et une tuile de Réglages rapides.
|
||||
|
||||
Le design est du vrai Material 3 Expressive de bout en bout, avec couleurs dynamiques, animations et formes expressives.
|
||||
|
||||
Confidentialité : pas de télémétrie, pas d'analytique, pas de publicité. Agendula ne se connecte que pour synchroniser les comptes CalDAV que vous ajoutez. Sans compte, vos tâches ne quittent l'appareil que si vous les exportez.
|
||||
@@ -0,0 +1 @@
|
||||
Une appli de tâches Material 3 Expressive avec synchro CalDAV.
|
||||
@@ -0,0 +1 @@
|
||||
Agendula: Tasks
|
||||
@@ -0,0 +1,11 @@
|
||||
### Novidades
|
||||
- Lembretes podem contar a partir do início da tarefa ou do prazo.
|
||||
- Tarefas de dia inteiro podem ter lembretes em horários fixos.
|
||||
- Outros apps podem ler e editar suas tarefas locais via OpenTasks, com sua permissão.
|
||||
|
||||
### Alterações
|
||||
- Lembretes sincronizam como VALARM e tocam também no Lembretes da Apple, Thunderbird e tasks.org.
|
||||
|
||||
### Correções
|
||||
- Widgets voltam a funcionar após marcar tarefas.
|
||||
- O login por e-mail volta a funcionar no mailbox.org e Posteo.
|
||||
+1
-1
Submodule floret-kit updated: 68d6432434...e8d33329c8
@@ -51,11 +51,10 @@ 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.
|
||||
dnsjava = "3.6.3"
|
||||
# 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.5"
|
||||
# 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.
|
||||
unifiedpush = "3.3.5"
|
||||
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when R8 keeps one of our classes by name but strips its no-arg constructor.
|
||||
|
||||
A keep rule that names a class but no members keeps the class and lets R8 drop
|
||||
the constructor nothing calls directly. Anything that then instantiates it by
|
||||
reflection (Room, Glance's ActionCallback, WorkManager) fails at runtime only,
|
||||
in release builds only (#1, #75). R8 reports both halves: seeds.txt lists what
|
||||
keep rules pinned, usage.txt what it removed.
|
||||
|
||||
scripts/check_r8_keeps.py [mapping dir] default: fullReleaseTest's
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_DIR = ROOT / "app/build/outputs/mapping/fullReleaseTest"
|
||||
PACKAGES = ("de.jeanlucmakiola.",)
|
||||
|
||||
|
||||
def kept_classes(seeds: Path) -> set[str]:
|
||||
return {
|
||||
line.strip()
|
||||
for line in seeds.read_text().splitlines()
|
||||
if line.startswith(PACKAGES) and ":" not in line
|
||||
}
|
||||
|
||||
|
||||
def classes_without_default_ctor(usage: Path) -> set[str]:
|
||||
stripped, current = set(), None
|
||||
for line in usage.read_text().splitlines():
|
||||
if not line.startswith(" "):
|
||||
current = line.rstrip(":") if line.endswith(":") else None
|
||||
elif current and line.strip().endswith(" <init>()"):
|
||||
stripped.add(current)
|
||||
return stripped
|
||||
|
||||
|
||||
def main() -> int:
|
||||
mapping = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DIR
|
||||
seeds, usage = mapping / "seeds.txt", mapping / "usage.txt"
|
||||
if not seeds.exists() or not usage.exists():
|
||||
print(f"ERROR: no seeds.txt/usage.txt in {mapping}; build a minified variant first.", file=sys.stderr)
|
||||
return 1
|
||||
broken = sorted(kept_classes(seeds) & classes_without_default_ctor(usage))
|
||||
for name in broken:
|
||||
print(f"ERROR: {name} is kept by a rule but R8 removed its no-arg constructor.", file=sys.stderr)
|
||||
if broken:
|
||||
print("Keep the constructor too: -keep class ... { <init>(); }", file=sys.stderr)
|
||||
return 1
|
||||
print(f"OK: every kept class under {', '.join(PACKAGES)} still has its no-arg constructor.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user