Skip to content
Open
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
52 changes: 48 additions & 4 deletions DYNAMIC_APP_DESCRIPTOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,42 @@ or request URL. Cancellation propagates without a failure event, and a recorder
failure never discards a usable contract. An unverified app version keeps
writes disabled without showing a permanent refresh spinner.

## Metadata fallback reasons

When no same-origin or App Store source yields a verified contract, discovery
keeps the metadata-only descriptor and records a typed reason in
[`DynamicContractFallback.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt).
The fallback card derives its title, explanation, and action from that reason:

- the inspected releases provide no verified contract for the installed version;
- the App Store lists no release matching the installed version;
- the app's linked source refused or rate limited the last fallback request
(HTTP 403 or 429), so a contract may still exist;
- the server or app version could not be confirmed;
- the App Store could not be reached, or did not provide the release;
- the package or its source failed verification;
- the verified contract cannot be shown natively yet;
- the client could not complete the check.

[`ContractAcquisitionFailure.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt)
classifies the module's own failures: HTTP status responses, verification and
origin rejections, malformed request values, network failures, and runtime
linkage errors. Writing a downloaded catalog to the local cache is best effort,
so a local storage failure is never reported as an unreachable App Store.
[`JvmAppStoreContractAcquisition.kt`](ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt)
translates them once for both JVM targets. It also catches `LinkageError`. If
acquisition code cannot initialize on a runtime, for example because the
Android ICU engine rejects a regular expression, the result is a client-fault
fallback, not an exception class name. Diagnostics and product copy never
contain exception text, and a fallback reason is never persisted.

A completed acquisition without a contract is a typed outcome, not a bare
`null`.
[`ContractAcquisitionOutcome.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt)
separates no matching release, a temporarily unavailable linked source, and
releases inspected without a usable contract, and the shared JVM boundary
carries that reason to the fallback card.

## Read failures

[`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt)
Expand Down Expand Up @@ -213,10 +249,18 @@ bodies, invalid text, and deeply nested JSON.
non-default ports, IPv6, explicit mismatches, and untrusted templates.
- Acquisition rejects concrete package-server authorities because it cannot
prove the connected account owns them.
- Host placeholders are parsed without a brace regular expression (the `ui`
build checks `commonMain` for this). The contract module's ownership boundary
is also exercised by an Android instrumented test, to verify class
initialization and host rejection on the Android runtime.
- Host placeholders are parsed without a brace regular expression. The `ui`
build checks `commonMain`. `tools/check-kotlin-architecture.sh` checks the
shared `ui` source sets compiled for Android, the contract module, and every
`androidApp` source set except JVM unit tests.
- `AppOwnedOpenApiAndroidInstrumentedTest` initializes every top-level
contract-module class, exercises host rejection, the real catalog path with
a failing cache, and YAML parsing on the Android runtime. CI does not run
instrumented tests; run it with the emulator workflow in
[CONTRIBUTING.md](CONTRIBUTING.md). The JVM unit test
`ContractModuleInitializationTest` loads every facade and fails when one is
missing from the instrumented list. JVM loading does not prove ART or ICU
compatibility.
- Path and operation server overrides must pass the same origin checks and
resolve to the root server path base. Different override bases are rejected,
not ignored. An absent override inherits the base; an explicit empty array
Expand Down
11 changes: 6 additions & 5 deletions PLATFORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ modules (`settings.gradle.kts`); the Rust crate is built separately.
| Location | What it owns |
| --- | --- |
| `ui/src/commonMain` | Platform-neutral models, policies, repositories, the dynamic descriptor compiler (`nativeui/model`), the native renderer (`nativeui/runtime`), and Compose screens. The `NextcloudPlatformServices` interface is declared here. |
| `ui/src/jvmMain` | Code that is identical on Android and desktop: OkHttp request helpers, the authenticated app-read session, detached downloads, resumable uploads, text-draft encryption, and support diagnostics. |
| `ui/src/jvmMain` | Code that is identical on Android and desktop: OkHttp request helpers, the authenticated app-read session, the App Store contract acquisition boundary, detached downloads, resumable uploads, text-draft encryption, and support diagnostics. |
| `ui/src/androidMain` | Android `actual` implementations used by the shared UI, such as audio and video playback services, image decoding, back handling, the embedded Office editor, and server certificate trust. |
| `ui/src/desktopMain` | The complete desktop platform: `DesktopNextcloudServices`, secret stores, the desktop sync engine and its SQLite stores, Linux FUSE virtual files, Windows Cloud Files, tray integration, and packaging resources. |
| `androidApp/src/main` | The Android application: `MainActivity`, `AndroidNextcloudServices`, Keystore-backed credentials, WorkManager workers, `NextcloudDocumentsProvider`, folder sync, media backup, and share-target activities. |
| `contractAcquisition` | A plain Kotlin/JVM module that verifies signed App Store packages and extracts app contracts. Both `androidApp` and the desktop target depend on it; `commonMain` does not. |
| `contractAcquisition` | A plain Kotlin/JVM module that verifies signed App Store packages and extracts app contracts. `androidApp` and both `ui` JVM targets depend on it, so shared `jvmMain` code can translate its results; `commonMain` does not. |
| `src/` (Rust crate) | The reference schema and descriptor compiler, plus the Windows Explorer shell registrar binary that the desktop Windows package bundles. The Rust compiler is not linked into the app. |

`jvmMain` is not a separate Kotlin Multiplatform source set. The `ui` build adds
Expand Down Expand Up @@ -72,9 +72,10 @@ figures came from comparing function bodies after removing the `Android` and
conflicts, checkpoints), groupware and media DAV models, virtual-file eviction
policy, and the `NextcloudPlatformServices` interface live in `commonMain`.
Both service containers implement that interface. `jvmMain` shares login and
authenticated request policy, application read sessions, detached downloads,
the resumable chunk-upload state machine, staging-space reservations, support
diagnostics and intake, text-draft encryption, and WebDAV href decoding.
authenticated request policy, application read sessions, App Store contract
acquisition results and failures, detached downloads, the resumable
chunk-upload state machine, staging-space reservations, support diagnostics and
intake, text-draft encryption, and WebDAV href decoding.

**Still duplicated.** The gap is below the shared interface: each platform has
its own HTTP request code, protocol adapters, XML parsers, and sync executor.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
package dev.obiente.nextcloudnative

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dev.obiente.nextcloudnative.app.AppStoreContractAcquisition
import dev.obiente.nextcloudnative.app.AppStoreContractAcquisitionException
import dev.obiente.nextcloudnative.app.AppStoreContractFailureKind
import dev.obiente.nextcloudnative.app.AppStoreContractUnavailableReason
import dev.obiente.nextcloudnative.app.acquireAppStoreContract
import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest
import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache
import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer
import java.io.File
import java.lang.reflect.Method
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
Expand Down Expand Up @@ -35,6 +50,69 @@ class AppOwnedOpenApiAndroidInstrumentedTest {
}
}

@Test
fun everyContractStaticInitializerAndFailureBoundaryWorkOnAndroid() {
// Initialize each packaged top-level facade and companion owner on ART and ICU. The JVM
// unit test ContractModuleInitializationTest fails when a facade is missing from this list.
listOf(
"AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "ContractAcquisitionFailureKt",
"NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt",
"StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt",
"VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer",
"FileAppStoreCatalogCache", "DynamicApiResponseCache", "FileVerifiedContractCache",
).forEach { name -> Class.forName("dev.obiente.nextcloudnative.contracts.$name") }
Class.forName("dev.obiente.nextcloudnative.app.JvmAppStoreContractAcquisitionKt")

val failed = try {
acquireAppStoreContract(ContractAcquisitionRequest("example", "34.0.3", "1.0.0")) {
throw NoClassDefFoundError("dev.obiente.nextcloudnative.contracts.SyntheticKt")
}
null
} catch (failure: AppStoreContractAcquisitionException) {
failure
}
assertEquals(AppStoreContractFailureKind.RuntimeIncompatible, failed?.kind)
assertFalse(failed?.message.orEmpty().contains("SyntheticKt"))
}

@Test
fun realCatalogPathAndYamlParserWorkOnAndroid() {
val cacheRoot = File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "catalog-cache-test")
cacheRoot.deleteRecursively()
// A regular file where the cache directory belongs makes catalog publication fail on ART.
check(cacheRoot.mkdirs() && File(cacheRoot, "blocked").createNewFile())
try {
MockWebServer().use { server ->
server.start()
repeat(2) { server.enqueue(MockResponse(body = """[{"id":"other","certificate":"c","releases":[]}]""")) }
val acquirer = SignedAppStoreContractAcquirer(
httpClient = OkHttpClient(),
appStoreBaseUrl = server.url("api/v1").toString(),
requireHttps = false,
catalogCache = FileAppStoreCatalogCache(File(File(cacheRoot, "blocked"), "catalogs")),
)
val outcome = acquireAppStoreContract(
ContractAcquisitionRequest("example", "34.0.3", "1.0.0"),
acquirer::acquireOutcome,
)
assertEquals(
AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoMatchingRelease),
outcome,
)
assertEquals(2, server.requestCount)
}
} finally {
cacheRoot.deleteRecursively()
}

val parseYaml = Class.forName("dev.obiente.nextcloudnative.contracts.OpenApiYamlParserKt")
.getDeclaredMethod("parseYamlObject", String::class.java)
val yaml = "openapi: 3.0.1\nservers: [{ url: /apps/example }]\npaths:\n /items: {}\n"
val document = parseYaml.invoke(null, yaml) as JSONObject
assertEquals("3.0.1", document.optString("openapi"))
assertTrue(document.getJSONObject("paths").has("/items"))
}

private fun Method.accepts(server: String): Boolean {
val document = """{"openapi":"3.1.1","servers":[{"url":"$server"}],"paths":{"/items":{}}}"""
return invoke(null, "example", "openapi.json", document) as Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Base64
import android.util.Log
import dev.obiente.nextcloudnative.app.AcquiredOpenApiContract
import dev.obiente.nextcloudnative.app.AcquiredOpenApiContractSourceKind
import dev.obiente.nextcloudnative.app.AcquiredContractKind
import dev.obiente.nextcloudnative.app.AppStoreContractAcquisition
import dev.obiente.nextcloudnative.app.acquireAppStoreContract
import dev.obiente.nextcloudnative.app.DeckAttachment
import dev.obiente.nextcloudnative.app.DeckAttachmentOpenTarget
import dev.obiente.nextcloudnative.app.DeckCardDraftKey
Expand Down Expand Up @@ -232,11 +231,9 @@ import dev.obiente.nextcloudnative.app.systemTagsDavDiscoveryRequest
import dev.obiente.nextcloudnative.app.toWebDavMutationSpec
import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest
import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse
import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind
import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache
import dev.obiente.nextcloudnative.contracts.FileVerifiedContractCache
import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer
import dev.obiente.nextcloudnative.contracts.VerifiedContractKind
import java.io.File
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
Expand Down Expand Up @@ -2963,35 +2960,11 @@ internal class AndroidNextcloudServices(
appId: String,
serverVersion: String,
installedAppVersion: String?,
): AcquiredOpenApiContract? = withContext(Dispatchers.IO) {
contractAcquirer.acquire(ContractAcquisitionRequest(appId, serverVersion, installedAppVersion))
?.let { contract ->
AcquiredOpenApiContract(
appId = contract.appId,
appVersion = contract.appVersion,
contractVersion = contract.contractVersion,
specFile = contract.specFile,
document = contract.document,
packageUrl = contract.packageUrl,
sourceUrl = contract.sourceUrl,
sourceKind = when (contract.sourceKind) {
OpenApiContractSourceKind.SignedAppPackage ->
AcquiredOpenApiContractSourceKind.SignedAppPackage
OpenApiContractSourceKind.SignedCompatibleAppPackage ->
AcquiredOpenApiContractSourceKind.SignedCompatibleAppPackage
OpenApiContractSourceKind.AppStoreLinkedExactGitHubTag ->
AcquiredOpenApiContractSourceKind.AppStoreLinkedExactGitHubTag
OpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag ->
AcquiredOpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag
},
contractKind = when (contract.contractKind) {
VerifiedContractKind.OpenApi -> AcquiredContractKind.OpenApi
VerifiedContractKind.VerifiedReadRoutes -> AcquiredContractKind.VerifiedReadRoutes
VerifiedContractKind.OpenApiWithVerifiedReadRoutes ->
AcquiredContractKind.OpenApiWithVerifiedReadRoutes
},
)
}
): AppStoreContractAcquisition = withContext(Dispatchers.IO) {
acquireAppStoreContract(
ContractAcquisitionRequest(appId, serverVersion, installedAppVersion),
contractAcquirer::acquireOutcome,
)
}

override suspend fun listActivities(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest
import java.io.File
import java.util.jar.JarFile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Loads every packaged contract-module facade on the JVM and keeps the Android instrumented
* initializer list exhaustive. This complements, and does not replace, the run on ART and ICU.
*/
class ContractModuleInitializationTest {
@Test
fun everyContractFacadeInitializesAndIsListedForAndroidRuntimeCoverage() {
val facades = packagedFacades()
assertTrue(facades.size >= 10, "The contract module facades were not found: $facades")
facades.forEach { name -> Class.forName("$CONTRACT_PACKAGE.$name") }
val instrumented = File(INSTRUMENTED_TEST).readText()
assertEquals(emptyList(), facades.filterNot { name -> "\"$name\"" in instrumented })
}

private fun packagedFacades(): List<String> {
val location = File(ContractAcquisitionRequest::class.java.protectionDomain.codeSource.location.toURI())
val prefix = CONTRACT_PACKAGE.replace('.', '/') + "/"
val entries = if (location.isDirectory) {
location.walkTopDown().filter(File::isFile).map { it.relativeTo(location).invariantSeparatorsPath }.toList()
} else {
JarFile(location).use { jar -> jar.entries().asSequence().map { it.name }.toList() }
}
return entries
.filter { it.startsWith(prefix) && it.endsWith("Kt.class") && '$' !in it }
.map { it.removePrefix(prefix).removeSuffix(".class") }
.filter { '/' !in it }
.sorted()
}

private companion object {
const val CONTRACT_PACKAGE = "dev.obiente.nextcloudnative.contracts"
const val INSTRUMENTED_TEST =
"src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt"
}
}
7 changes: 7 additions & 0 deletions changes/unreleased/473-explained-app-contract-fallbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 473
pull: none
platforms: android, desktop
user-facing: yes

An app without a verified native view now explains why, such as no verified contract for its version or an unreachable App Store, and checking again reports the same reason.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 493
pull: none
platforms: android, desktop
user-facing: yes

Dynamic apps on Android no longer fail App Store contract discovery because a contract parser could not initialize. If contract code cannot start on a device, the app now shows an explained fallback instead of an internal class name.
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,31 @@ package dev.obiente.nextcloudnative.contracts

import java.io.File
import java.security.MessageDigest
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap

interface AppStoreCatalogCache {
fun load(url: String): ByteArray?
fun store(url: String, bytes: ByteArray)
}

/**
* Publishes a downloaded catalog without letting local storage decide the acquisition result.
*
* The catalog bytes are already in memory, so a read-only cache directory or a full disk only
* costs a later re-download. Like verified-contract caching, publication is best effort; a local
* write failure must never be reported as an unreachable App Store.
*/
internal fun AppStoreCatalogCache.storeBestEffort(url: String, bytes: ByteArray) {
try {
store(url, bytes)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// The next acquisition downloads the catalog again.
}
}

class MemoryAppStoreCatalogCache : AppStoreCatalogCache {
private val entries = ConcurrentHashMap<String, ByteArray>()

Expand Down
Loading
Loading