Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,9 @@ fun SignInContent(state: SignInScreenState, onSignIn: (TokenRequest.Provider) ->
ButtonKind.SECONDARY,
enabled = enabled,
) { onSignIn(TokenRequest.Provider.dev) }
// Android does not offer Apple (ZQ23).
TokenRequest.Provider.apple -> Unit
// Android does not offer Apple (ZQ23). SignInProviders.shown
// never lists the generator's unknown case (T106).
TokenRequest.Provider.apple, TokenRequest.Provider.unknown_default_open_api -> Unit
}
}
}
Expand Down Expand Up @@ -396,7 +397,8 @@ suspend fun signIn(
when (provider) {
TokenRequest.Provider.google -> if (GoogleIdTokens.isConfigured(app.config.googleWebClientId)) google(nonce) else ProviderToken.Failed
TokenRequest.Provider.dev -> FlavorFeatures.developerIdToken?.let(ProviderToken::Token) ?: ProviderToken.Failed
TokenRequest.Provider.apple -> ProviderToken.Failed
// The app never signs in with the generator's unknown case (T106).
TokenRequest.Provider.apple, TokenRequest.Provider.unknown_default_open_api -> ProviderToken.Failed
}
}
if (outcome == SignInFlow.Outcome.SignedIn) app.completeSignIn(provider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ class AppModel(
/** The read pipeline with the bundled lists and the server's list version (FR-044). */
val pipeline: ReadPipeline get() = ReadPipeline.of(bundledLists, discovery?.wordlistVersion)

/** Discovery decides the sign-in buttons (FR-040). */
val authProviders: List<String> get() = discovery?.authProviders.orEmpty().map { it.value }
/**
* Discovery decides the sign-in buttons (FR-040). A provider the contract
* does not list yet has no button, so it is dropped (T106), as on iOS.
*/
val authProviders: List<String> get() = discovery?.authProviders.orEmpty()
.filter { it != Discovery.AuthProviders.unknown_default_open_api }
.map { it.value }

// MARK: Launch and foreground

Expand Down Expand Up @@ -124,7 +129,8 @@ class AppModel(

/** Account shows the provider the server reports (runtime.md, Localization). */
suspend fun noteAccount(account: Account) {
val first = account.providers.firstOrNull() ?: return
// A provider the contract does not list yet has no name to show (T106).
val first = account.providers.firstOrNull { it != Account.Providers.unknown_default_open_api } ?: return
providerStore.set(first.value)
provider = first.value
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ val OwnedCode.Status.res: Int
OwnedCode.Status.used -> R.string.codes_status_used
OwnedCode.Status.expired -> R.string.codes_status_expired
OwnedCode.Status.revoked -> R.string.codes_status_revoked
// A status the contract does not list yet (T106). It is not active, so
// the screens offer no Revoke. There is no copy for it, and copy comes
// only from zzThis, so it shows as expired, the nearest not-active
// state. iOS decodes the same value to expired, so both apps agree.
OwnedCode.Status.unknown_default_open_api -> R.string.codes_status_expired
}

@get:StringRes
Expand All @@ -59,4 +64,7 @@ val ReportRequest.Reason.res: Int
ReportRequest.Reason.harassment -> R.string.report_reason_harassment
ReportRequest.Reason.personalMinusData -> R.string.report_reason_personal_data
ReportRequest.Reason.other -> R.string.report_reason_other
// A request enum: the app picks the reason from REASONS and never
// sends this generated case (T106). Labelled as other if it ever did.
ReportRequest.Reason.unknown_default_open_api -> R.string.report_reason_other
}
11 changes: 11 additions & 0 deletions apps/android/core-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,18 @@ val openApiGenerate = tasks.register<JavaExec>("openApiGenerate") {
"dateLibrary=java8",
"sourceFolder=src/main/kotlin",
"omitGradleWrapper=true",
// zzThis RM-075, D-2026-10-13-09 (tasks.md T106, #54): a value the
// contract does not list yet decodes to the generated
// unknown_default_open_api case instead of failing the whole
// response. Every `when` over a generated enum handles that case.
"enumUnknownDefaultCase=true",
).joinToString(","),
// The generator escapes Discovery's `contract` property as a reserved
// word. With enumUnknownDefaultCase, its enum serializer template then
// writes the escaped name HTML-encoded (`&#x60;Contract&#x60;Serializer`),
// which does not compile (OpenAPI Generator 7.25.0). Only the Kotlin
// name changes; the wire name stays "contract".
"--name-mappings", "contract=contractVersion",
"--global-property", "apiTests=false,modelTests=false,apiDocs=false,modelDocs=false",
// Generator configuration, not hand-written types (runtime.md, API
// client step 6). The app builds its own Retrofit and OkHttp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ class DemoApi : MetaApi, AuthApi, CodesApi, RecordsApi, ReportsApi {
)

val DISCOVERY = Discovery(
contract = Discovery.Contract._1,
contractVersion = Discovery.ContractVersion._1,
freePublic = true,
authProviders = listOf(Discovery.AuthProviders.apple, Discovery.AuthProviders.google),
scopes = listOf(Discovery.Scopes.free_public),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,18 +234,24 @@ class ZZService(
}
return try {
when (status) {
// An error code the contract does not list decodes to the
// generator's unknown case (T106). An error body is not a
// record, so it stays what it was before T106: a body that
// does not match its schema, which is unavailable.
404 -> {
ZZJson.strict.decodeFromString(NotFoundError.serializer(), text)
ApiFailure.NotFound
val error = ZZJson.strict.decodeFromString(NotFoundError.serializer(), text)
if (error.error == NotFoundError.Error.unknown_default_open_api) ApiFailure.Unavailable else ApiFailure.NotFound
}
429 -> {
ZZJson.strict.decodeFromString(RateLimitedError.serializer(), text)
val error = ZZJson.strict.decodeFromString(RateLimitedError.serializer(), text)
if (error.error == RateLimitedError.Error.unknown_default_open_api) return ApiFailure.Unavailable
val retryAfter = response.headers()["Retry-After"]?.trim()?.toIntOrNull()?.takeIf { it >= 1 }
if (retryAfter == null) ApiFailure.Unavailable else ApiFailure.RateLimited(retryAfter)
}
else -> {
val error = ZZJson.strict.decodeFromString(Error.serializer(), text)
when {
error.error == Error.Error.unknown_default_open_api -> ApiFailure.Unavailable
status == 401 -> ApiFailure.Unauthorized
error.error == Error.Error.contractMinusVersion -> ApiFailure.ContractVersion
error.error == Error.Error.notMinusFound -> ApiFailure.NotFound
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,17 @@ class DecodingTest {
}
}

/**
* Before T106 this body was refused. zzThis D-2026-10-13-09 (RM-075, #54)
* reverses that: a value contract 1 does not list decodes to the
* generator's unknown case, and the rest of the body is kept.
*/
@Test
fun anEnumValueOutsideContract1IsRefused() = runBlocking {
fun anEnumValueOutsideContract1DecodesToTheUnknownCase() = runBlocking {
ApiStub { ApiStub.Reply(200, Fixtures.ACCOUNT.replace("\"dev\"", "\"github\"")) }.use { stub ->
assertEquals(ApiFailure.Unavailable, stub.service(accessToken = "a").me().failureOrNull)
val account = (stub.service(accessToken = "a").me() as ApiResult.Ok).value
assertEquals(listOf(Account.Providers.unknown_default_open_api), account.providers)
assertEquals("account-1", account.id)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package com.zer0state.zzthat.api

import com.zer0state.zzthat.api.generated.models.Discovery
import com.zer0state.zzthat.api.generated.models.OwnedCode
import com.zer0state.zzthat.api.generated.models.Resolve
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test

/**
* tasks.md T095, zzThis RM-075 (D-2026-10-10-12): the generated client is a
* tolerant reader, so an additive, optional field such as the planned
* Resolve `creator` (RM-112) does not break a shipped app. An unknown enum
* value is not tolerated yet: issue #54.
* Resolve `creator` (RM-112) does not break a shipped app. A value a
* response enum does not list yet decodes to the generator's
* unknown_default_open_api case (T106, #54, D-2026-10-13-09).
*/
class TolerantReaderTest {
private fun resolve(body: String) = runBlocking {
Expand All @@ -24,4 +28,45 @@ class TolerantReaderTest {
)
assertEquals("Lost cat", (result as ApiResult.Ok).value.record.record.title)
}

@Test
fun resolveWithAnUnknownViewShowsTheRecord() {
val result = resolve(
"""{"view":"auditor","canonical":"zz-kathy-lost-cat-zz",""" +
""""record":{"title":"Lost cat","body":"Answers on this page.","updated_at":"2026-10-04T00:00:00.123Z"},""" +
""""share":{"text":"zz-kathy-lost-cat-zz","url":null}}""",
)
val record = (result as ApiResult.Ok).value.record
assertEquals(Resolve.View.unknown_default_open_api, record.view)
assertEquals("Lost cat", record.record.title)
assertEquals("Answers on this page.", record.record.body)
assertEquals("zz-kathy-lost-cat-zz", record.share.text)
}

@Test
fun codesWithAnUnknownStatusAndScopeDecode() = runBlocking {
val page = Fixtures.CODE_PAGE.replace("\"active\"", "\"paused\"").replace("\"free_public\"", "\"galaxy\"")
val result = ApiStub { ApiStub.Reply(200, page) }.use { stub -> stub.service(accessToken = "a").myCodes() }
val row = (result as ApiResult.Ok).value.codes.single()
assertEquals(OwnedCode.Status.unknown_default_open_api, row.status)
assertEquals(OwnedCode.Scope.unknown_default_open_api, row.scope)
assertEquals("Lost cat", row.title)
}

@Test
fun discoveryWithAnUnknownProviderDecodes() = runBlocking {
val body = Fixtures.DISCOVERY.replace("[\"dev\"]", "[\"passkey\",\"dev\"]")
val result = ApiStub { ApiStub.Reply(200, body) }.use { stub -> stub.service().discovery() }
val discovery = (result as ApiResult.Ok).value
assertEquals(listOf(Discovery.AuthProviders.unknown_default_open_api, Discovery.AuthProviders.dev), discovery.authProviders)
}

/** An error body is not a record: an error code the contract does not list stays unavailable, as before T106. */
@Test
fun anUnknownErrorCodeIsUnavailable() = runBlocking {
val result = ApiStub { ApiStub.Reply(403, """{"error":"brand-new"}""") }.use { stub ->
stub.service(accessToken = "a").myCodes()
}
assertEquals(ApiFailure.Unavailable, result.failureOrNull)
}
}
Loading
Loading