Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0279d6afb | ||
|
|
02a8a155c3 | ||
|
|
0b8287eb13 | ||
|
|
4f86dc46bb | ||
|
|
ffc5900db4 |
@@ -195,6 +195,15 @@ jobs:
|
|||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew assembleDebug
|
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
|
- name: Trivy filesystem scan
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -50,6 +50,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
|
Notifications, Task form is now New task form, and export and import moved
|
||||||
out of Storage into a new Backup & restore section (#66).
|
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
|
## [1.1.0] - 2026-10-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ android {
|
|||||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
// (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.
|
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 1020001
|
versionCode = 1020002
|
||||||
versionName = "1.2.0-beta.1"
|
versionName = "1.2.0-beta.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
# nothing calls it directly — Room then throws InstantiationException, reported
|
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||||
# as "Failed to create an instance of ...". This first bit us through a
|
# as "Failed to create an instance of ...". This first bit us through a
|
||||||
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
||||||
# issue #1); Glance is gone and Room is now our own task store, so the rule
|
# issue #1); Room is now also our own task store, so the rule matters more,
|
||||||
# matters more, not less — TasksDatabase is built on the first store read.
|
# not less — TasksDatabase is built on the first store read.
|
||||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# 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.AppPassword
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||||
import de.jeanlucmakiola.caldav.DnsJavaResolver
|
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -75,7 +75,8 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
val client = credentials?.let {
|
val client = credentials?.let {
|
||||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
||||||
} ?: CalDavHttp.anonymous(userAgent)
|
} ?: 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? =
|
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
|
R.string.add_account_provider_title to R.string.add_account_provider_body
|
||||||
is AddAccountStep.PrepareAccess ->
|
is AddAccountStep.PrepareAccess ->
|
||||||
step.quirk.setupTitle to R.string.add_account_setup_body
|
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
|
R.string.add_account_email_title to R.string.add_account_email_body
|
||||||
} else {
|
} else {
|
||||||
R.string.add_account_server_title to R.string.add_account_server_body
|
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 = "",
|
val input: String = "",
|
||||||
/** A cause, not a message — the screen owns the wording. */
|
/** A cause, not a message — the screen owns the wording. */
|
||||||
val error: CalDavDiscovery.Outcome.Cause? = null,
|
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
|
data class Working(val message: AddAccountMessage.Progress) : AddAccountStep
|
||||||
|
|
||||||
@@ -279,6 +284,11 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
get() = handle[KEY_REAUTH_ACCOUNT]
|
get() = handle[KEY_REAUTH_ACCOUNT]
|
||||||
set(value) { handle[KEY_REAUTH_ACCOUNT] = value }
|
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. */
|
/** The username the credentials step starts with on a re-authentication. */
|
||||||
private var suggestedUsername: String
|
private var suggestedUsername: String
|
||||||
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
|
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
|
||||||
@@ -370,6 +380,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// one is not an answer to this one.
|
// one is not an answer to this one.
|
||||||
if (picked != choice) addressInput = ""
|
if (picked != choice) addressInput = ""
|
||||||
choice = picked
|
choice = picked
|
||||||
|
clearFallback()
|
||||||
val quirk = picked.provider?.let(ServerQuirk::forProvider)
|
val quirk = picked.provider?.let(ServerQuirk::forProvider)
|
||||||
val setup = quirk?.hasSetupSteps == true
|
val setup = quirk?.hasSetupSteps == true
|
||||||
updateState {
|
updateState {
|
||||||
@@ -430,11 +441,10 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
addressInput = value
|
addressInput = value
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.EnterAddress(picked, value),
|
step = addressStep(value),
|
||||||
// The service's own rule outranks the one the typed host implies:
|
// 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.
|
// it was chosen deliberately, and it is known before a key is hit.
|
||||||
quirk = picked.provider?.let(ServerQuirk::forProvider)
|
quirk = quirkFor(value),
|
||||||
?: ServerQuirk.forInput(value),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,13 +515,47 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// would make it permanently unreachable.
|
// would make it permanently unreachable.
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
|
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. */
|
/** Step 3a → re-run discovery with the password the user typed. */
|
||||||
fun onCredentialsSubmitted() {
|
fun onCredentialsSubmitted() {
|
||||||
val step = current.step as? AddAccountStep.EnterCredentials ?: return
|
val step = current.step as? AddAccountStep.EnterCredentials ?: return
|
||||||
@@ -549,8 +593,8 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
|
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,7 +621,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.EnterCredentials(
|
step = AddAccountStep.EnterCredentials(
|
||||||
username = suggestedUsername,
|
username = prefilledUsername(),
|
||||||
error = quirkHint(),
|
error = quirkHint(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -803,7 +847,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null),
|
step = AddAccountStep.EnterCredentials(username = prefilledUsername(), error = null),
|
||||||
openInBrowser = 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
|
// 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.
|
// without starting the flow again — that is the screen people return to.
|
||||||
is AddAccountStep.EnterAddress -> {
|
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,6 +1044,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
hostsNeedingAuth = emptyList()
|
hostsNeedingAuth = emptyList()
|
||||||
reauthAccountId = null
|
reauthAccountId = null
|
||||||
suggestedUsername = ""
|
suggestedUsername = ""
|
||||||
|
fallbackEmail = null
|
||||||
current = AddAccountUiState()
|
current = AddAccountUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,8 +1174,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
reauthenticating = reauthAccountId != null,
|
reauthenticating = reauthAccountId != null,
|
||||||
// Derived rather than saved: it is a pure function of the choice and the
|
// Derived rather than saved: it is a pure function of the choice and the
|
||||||
// address, both of which are.
|
// address, both of which are.
|
||||||
quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
quirk = quirkFor(addressInput),
|
||||||
?: ServerQuirk.forInput(addressInput),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1148,8 +1202,8 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||||
when (val outcome = gateway.discover(target, credentials)) {
|
when (val outcome = gateway.discover(target, credentials)) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
||||||
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)
|
||||||
// The credential we were holding is no longer accepted, which on
|
// The credential we were holding is no longer accepted, which on
|
||||||
// this path is indistinguishable from never having had one.
|
// this path is indistinguishable from never having had one.
|
||||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||||
@@ -1250,6 +1304,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
choice = choice ?: ProviderChoice.OtherServer,
|
choice = choice ?: ProviderChoice.OtherServer,
|
||||||
input = input,
|
input = input,
|
||||||
error = error,
|
error = error,
|
||||||
|
manualServer = fallbackEmail != null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What was actually set up, named the way the accounts list will name it. */
|
/** 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)) }
|
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. */
|
/** The provider-specific reason a correct-looking password gets rejected. */
|
||||||
private fun quirkHint(): AddAccountMessage? =
|
private fun quirkHint(): AddAccountMessage? =
|
||||||
when (
|
when (val quirk = quirkFor(typedInput)) {
|
||||||
val quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
|
||||||
?: ServerQuirk.forInput(typedInput)
|
|
||||||
) {
|
|
||||||
ServerQuirk.FASTMAIL_APP_PASSWORD,
|
ServerQuirk.FASTMAIL_APP_PASSWORD,
|
||||||
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
|
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
|
||||||
-> AddAccountMessage.Quirk(quirk)
|
-> AddAccountMessage.Quirk(quirk)
|
||||||
@@ -1377,6 +1433,14 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
|
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
|
||||||
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
||||||
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
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.
|
* What to type for a server, from the principal an account stored.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import de.jeanlucmakiola.agendula.R
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
|
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)) {
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
FieldRow(
|
FieldRow(
|
||||||
label = if (hosted) {
|
label = if (hosted) {
|
||||||
@@ -30,7 +30,7 @@ internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccoun
|
|||||||
},
|
},
|
||||||
value = step.input,
|
value = step.input,
|
||||||
onValueChange = viewModel::onAddressChanged,
|
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),
|
?: stringResource(R.string.add_account_server_hint),
|
||||||
error = step.error?.let { stringResource(it.message) },
|
error = step.error?.let { stringResource(it.message) },
|
||||||
// An email address is still typed with the URI keyboard: it is the
|
// 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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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
|
* affordance appears once a value is set. Tappable values read as links
|
||||||
* (primary); an error flips them to the error colour.
|
* (primary); an error flips them to the error colour.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScheduleRow(
|
private fun ScheduleRow(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -893,43 +895,76 @@ private fun ScheduleRow(
|
|||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp)
|
||||||
Text(
|
LabelledValueLayout(
|
||||||
text = label,
|
label = {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (value == null) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.edit_set),
|
text = label,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
},
|
||||||
Text(
|
value = {
|
||||||
text = value.formatDate(allDay),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
if (value == null) {
|
||||||
color = valueColor,
|
Text(
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
text = stringResource(R.string.edit_set),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
if (!allDay) {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Text(
|
modifier = valueModifier,
|
||||||
text = value.formatTime(LocalUse24HourFormat.current),
|
)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
} else {
|
||||||
color = valueColor,
|
FlowRow(
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
horizontalArrangement = Arrangement.End,
|
||||||
)
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
) {
|
||||||
Icon(
|
Text(
|
||||||
Icons.Rounded.Clear,
|
text = value.formatDate(allDay),
|
||||||
contentDescription = stringResource(R.string.edit_clear),
|
style = MaterialTheme.typography.titleMedium,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = valueColor,
|
||||||
modifier = Modifier.size(20.dp),
|
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),
|
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
TaskWidgetHeader(title, filter, ready?.singleListId)
|
TaskWidgetHeader(title, filter, ready?.singleListId)
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
|
||||||
when (data) {
|
when (data) {
|
||||||
null -> Unit
|
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 ->
|
is TaskWidgetData.Ready ->
|
||||||
if (data.rows.isEmpty()) {
|
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))
|
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
|
||||||
} else {
|
} else {
|
||||||
val allSections = widgetSections(
|
val allSections = widgetSections(
|
||||||
@@ -213,6 +216,12 @@ private fun TaskWidgetBody(
|
|||||||
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
|
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
|
||||||
onlyHeader.key == "due:${redundantDueKind.name}"
|
onlyHeader.key == "due:${redundantDueKind.name}"
|
||||||
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
|
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()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(sections.size) { index ->
|
items(sections.size) { index ->
|
||||||
when (val section = sections[index]) {
|
when (val section = sections[index]) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="@dimen/widget_tasks_header_gap"
|
||||||
android:minHeight="40dp"
|
android:minHeight="40dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
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_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_summary_done">Done</string>
|
||||||
<string name="add_account_server_title">Where are your tasks?</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_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_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>
|
<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.
|
// The input survives, so the user can correct it rather than retype it.
|
||||||
assertThat(step.input).isEqualTo("https://example.com/")
|
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
|
@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,
|
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
|
* 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.
|
* without a network, and so the resolver can be swapped per platform.
|
||||||
*/
|
*/
|
||||||
interface DnsResolver {
|
interface DnsResolver {
|
||||||
fun srv(name: String): List<SrvRecord>
|
/** The SRV and TXT records for [name], looked up together. */
|
||||||
fun txt(name: String): List<String>
|
fun lookup(name: String): DnsRecords
|
||||||
|
|
||||||
/** For the base-URL path, where DNS is never consulted. */
|
/** For the base-URL path, where DNS is never consulted. */
|
||||||
object None : DnsResolver {
|
object None : DnsResolver {
|
||||||
override fun srv(name: String) = emptyList<SrvRecord>()
|
override fun lookup(name: String) = DnsRecords(emptyList(), emptyList())
|
||||||
override fun txt(name: String) = emptyList<String>()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,14 +114,16 @@ object ServiceDiscovery {
|
|||||||
val domain = domainOf(trimmed) ?: return emptyList()
|
val domain = domainOf(trimmed) ?: return emptyList()
|
||||||
val candidates = mutableListOf<Candidate>()
|
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 }
|
.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.
|
// "Calendar.Google.com." would otherwise walk straight past the guard.
|
||||||
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
||||||
// ⚠️ RFC 2782 does not require a target to live inside the domain it
|
// ⚠️ 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
|
// was queried for, and the answer is not DNSSEC-validated (Private
|
||||||
// no DNSSEC — then walked *again* after a 401, with the
|
// 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
|
// authenticated client. An out-of-domain target is also one the
|
||||||
// credential would never be offered to, since CalDavHttp scopes it
|
// credential would never be offered to, since CalDavHttp scopes it
|
||||||
// to the typed address's registrable domain, so following it can
|
// 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
|
// 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.
|
// 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 ->
|
.firstNotNullOfOrNull { record ->
|
||||||
record.split(' ', ';')
|
record.split(' ', ';')
|
||||||
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
||||||
@@ -205,6 +209,19 @@ object ServiceDiscovery {
|
|||||||
return input.toHttpUrlOrNull()
|
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. */
|
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
|
||||||
internal fun domainOf(input: String): String? {
|
internal fun domainOf(input: String): String? {
|
||||||
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
|
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 srv: Map<String, List<SrvRecord>> = emptyMap(),
|
||||||
val txt: Map<String, List<String>> = emptyMap(),
|
val txt: Map<String, List<String>> = emptyMap(),
|
||||||
) : DnsResolver {
|
) : DnsResolver {
|
||||||
override fun srv(name: String) = srv[name].orEmpty()
|
override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty())
|
||||||
override fun txt(name: String) = txt[name].orEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
||||||
@@ -153,6 +152,15 @@ class ServiceDiscoveryTest {
|
|||||||
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
|
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
|
@Test
|
||||||
fun `something that is neither an address nor a URL yields nothing`() {
|
fun `something that is neither an address nor a URL yields nothing`() {
|
||||||
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
|
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
|
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`
|
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
|
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
|
query goes through Android's own resolver, so it uses your device's Private DNS
|
||||||
go to the domain you typed — no directory of servers is consulted and no lookup
|
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.
|
is sent to the developer.
|
||||||
|
|
||||||
### Signing in to a Nextcloud
|
### Signing in to a Nextcloud
|
||||||
|
|||||||
+1
-1
Submodule floret-kit updated: 68d6432434...e8d33329c8
@@ -51,10 +51,9 @@ okhttp = "4.12.0"
|
|||||||
xpp3 = "1.1.6"
|
xpp3 = "1.1.6"
|
||||||
|
|
||||||
# --- :caldav (our CalDAV protocol layer) -------------------------------------
|
# --- :caldav (our CalDAV protocol layer) -------------------------------------
|
||||||
# SRV/TXT lookups for RFC 6764 discovery. Android's resolver exposes no SRV API
|
# Parses the raw SRV/TXT answers for RFC 6764 discovery; the lookup itself goes
|
||||||
# below API 29's DnsResolver (which is callback-only and cannot do TXT paths),
|
# through Android's DnsResolver, so it follows the network and Private DNS.
|
||||||
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
|
# BSD-3 — it goes on the attribution screen with the rest.
|
||||||
# uses. BSD-3 — it goes on the attribution screen with the rest.
|
|
||||||
dnsjava = "3.6.3"
|
dnsjava = "3.6.3"
|
||||||
# WebDAV-Push delivery. The connector only: DAVx5 also ships the embedded FCM
|
# 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.
|
# distributor, which pulls in Play Services and would get us flagged on F-Droid.
|
||||||
|
|||||||
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