Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
d1e0f3d
fix: explain dynamic app contract fallbacks without exception text
veryCrunchy Oct 7, 2026
82153d6
chore(website): refresh marketing captures
obiente-automations[bot] Oct 8, 2026
7a556c4
fix(contracts): keep cancellation and cache writes out of failure kinds
veryCrunchy Oct 8, 2026
bd5fb72
fix(tools): scan every Android application source set for brace regexes
veryCrunchy Oct 8, 2026
b7816f4
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 8, 2026
9eb8dcf
Merge branch 'main' into fix/android-dynamic-contract-acquisition
veryCrunchy Oct 9, 2026
a56d86a
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 15, 2026
fbd14d2
refactor(contracts): move acquisition models into their own file
veryCrunchy Oct 15, 2026
b36b19f
refactor(contracts): move package trust verification into its own file
veryCrunchy Oct 15, 2026
f7f6df1
refactor(contracts): move OpenAPI YAML parsing into its own file
veryCrunchy Oct 15, 2026
b7ca52e
fix: carry a typed reason when App Store acquisition finds no contract
veryCrunchy Oct 15, 2026
830f783
test(android): cover every contract facade and the real catalog path
veryCrunchy Oct 15, 2026
55063a0
chore(website): refresh marketing captures
obiente-automations[bot] Oct 15, 2026
0b3de15
refactor(contracts): move App Store catalog release selection into it…
veryCrunchy Oct 16, 2026
bbdc51d
refactor: move same-origin dynamic contract discovery into its own file
veryCrunchy Oct 16, 2026
5fc72a4
fix(contracts): report malformed App Store catalog entries separately
veryCrunchy Oct 16, 2026
5c42d4b
fix: keep inconclusive server probes in dynamic contract fallback rea…
veryCrunchy Oct 16, 2026
271588f
chore(website): refresh marketing captures
obiente-automations[bot] Oct 16, 2026
c866f6a
fix(contracts): classify unreadable catalogs and linked archives as s…
veryCrunchy Oct 17, 2026
a6efb9f
fix: classify unreadable server contracts and access denials in probe…
veryCrunchy Oct 17, 2026
7853aea
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
5afd408
refactor(contracts): move signed package tar reading into its own file
veryCrunchy Oct 17, 2026
25c9ff4
fix: refresh the App Store catalog on an explicit discovery retry
veryCrunchy Oct 17, 2026
1a3e1fd
fix: keep recorded server probe reasons when the server version is mi…
veryCrunchy Oct 17, 2026
c74fdba
fix: treat unreadable OCS API Viewer spec lists as inconclusive
veryCrunchy Oct 17, 2026
ed99b54
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
d990b03
fix: classify every contract source failure and keep server reasons o…
veryCrunchy Oct 17, 2026
6714e5d
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
dd90eb8
fix(contracts): keep local verifier faults out of package verificatio…
veryCrunchy Oct 17, 2026
622f9ef
fix: carry typed diagnostic codes instead of matching diagnostic prose
veryCrunchy Oct 17, 2026
158c9e1
docs: date the reviewed contract and shared-ownership sections
veryCrunchy Oct 17, 2026
a58a41e
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
e9d8c2a
fix: refresh past cached contracts, name unreadable catalogs, and bou…
veryCrunchy Oct 17, 2026
0a5eda0
test(android): initialize the contract outcome facade on the Android …
veryCrunchy Oct 17, 2026
6ee6e6b
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
aa4acb3
fix(contracts): never serve a cached contract that could not be deleted
veryCrunchy Oct 17, 2026
dca1e2c
fix(contracts): reject catalog and contract values of the wrong JSON …
veryCrunchy Oct 17, 2026
45c0b08
fix(contracts): report unusable bundled trust material as a client fault
veryCrunchy Oct 17, 2026
8e8c22d
fix(contracts): keep the exact-release catalog reason after compatibl…
veryCrunchy Oct 17, 2026
8ef49e0
fix(contracts): stop stale acquisitions from overwriting newer verifi…
veryCrunchy Oct 17, 2026
5f4436d
fix(ui): offer no retry when the server rejected the account's creden…
veryCrunchy Oct 17, 2026
2301b0a
docs: describe trust faults, publication order, exact-release precede…
veryCrunchy Oct 17, 2026
2e622fe
test(ui): expect the sign-in guidance without a retry for rejected cr…
veryCrunchy Oct 17, 2026
998076a
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
1e79e6c
fix(contracts): let definitive no-contract results supersede older ac…
veryCrunchy Oct 17, 2026
ea83ab9
fix(contracts): classify bundled trust resource read failures as clie…
veryCrunchy Oct 17, 2026
54436ba
fix(contracts): derive no-contract reasons from one record of inspect…
veryCrunchy Oct 17, 2026
ba56fb5
fix(ui): read contract strings only from JSON strings in the probe an…
veryCrunchy Oct 17, 2026
96f0e5f
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 17, 2026
650a4f8
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 17, 2026
b29d84d
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
2d0936a
fix(contracts): reserve the generation in the same step that register…
veryCrunchy Oct 17, 2026
ac72453
fix(ui): treat unidentified API Viewer catalog entries as inconclusive
veryCrunchy Oct 17, 2026
f60743c
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 17, 2026
39a5ec6
chore(website): refresh marketing captures
obiente-automations[bot] Oct 17, 2026
b82cba4
fix(contracts): inspect fresh candidates when the refresh window refu…
veryCrunchy Oct 17, 2026
417de34
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 18, 2026
cd68d25
refactor(ui): move the dynamic app fallback card into its own file
veryCrunchy Oct 18, 2026
9b54f9b
feat(ui): offer account settings from the rejected-credentials fallba…
veryCrunchy Oct 18, 2026
0894aa2
chore(website): refresh marketing captures
obiente-automations[bot] Oct 18, 2026
21a5878
Merge remote-tracking branch 'origin/main' into fix/android-dynamic-c…
veryCrunchy Oct 19, 2026
652a78d
fix(contracts): serve cached contracts only while they are the latest…
veryCrunchy Oct 19, 2026
3cf4906
fix(contracts): treat differing duplicate app records as malformed ca…
veryCrunchy Oct 19, 2026
48f1a9c
fix(ui): treat differing duplicate API Viewer records as inconclusive
veryCrunchy Oct 19, 2026
bb8c22a
fix(ui): classify every unrecognized probe status as inconclusive
veryCrunchy Oct 19, 2026
441fcdf
chore(website): refresh marketing captures
obiente-automations[bot] Oct 19, 2026
6f04c73
fix(contracts): compare duplicate catalog records without JSON-java-o…
veryCrunchy Oct 19, 2026
bf88bb5
test(android): check the contract module's org.json references agains…
veryCrunchy Oct 19, 2026
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
285 changes: 281 additions & 4 deletions DYNAMIC_APP_DESCRIPTOR.md

Large diffs are not rendered by default.

26 changes: 15 additions & 11 deletions PLATFORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,19 @@ The app is built from four layers. Only the last one is platform-specific.

## Modules and source sets

This section describes where code lives today. The Gradle build has three
modules (`settings.gradle.kts`); the Rust crate is built separately.
**Last reviewed: 2026-10-17.** This section describes where code lives; the
code may have changed since, and the default branch is the source of truth.
The Gradle build has three 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, in-app video playback through vlcj and libVLC, 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 All @@ -63,18 +65,20 @@ behaves identically on both JVM targets belongs in `ui/src/jvmMain`. Code that
differs because of lifecycle, storage, or operating-system APIs stays in the
platform module, with the difference documented and tested.

**Last reviewed: 2026-10-02.** This is a source inventory, not a plan. The code
**Last reviewed: 2026-10-17.** This is a source inventory, not a plan. The code
may have changed since; the default branch is the source of truth. Similarity
figures came from comparing function bodies after removing the `Android` and
`Desktop` name prefixes.
figures came from comparing function bodies on 2026-10-02 after removing the
`Android` and `Desktop` name prefixes, and were not remeasured on the review
date.

**Already shared.** All Compose UI, the sync domain model (planning,
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
Comment thread
veryCrunchy marked this conversation as resolved.
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 All @@ -89,7 +93,7 @@ its own HTTP request code, protocol adapters, XML parsers, and sync executor.
| Medium | Dynamic discovery cache. Most of the logic is identical | `AndroidDynamicDiscoveryCache.kt` | `DesktopDynamicDiscoveryCache.kt` | One `jvmMain` cache |
| Medium | Deck card draft codec, capacity, and migration policy | `AndroidDeckCardDraftStore.kt` | `DesktopDeckCardDraftStore.kt` | `commonMain` policy behind a storage port |
| Medium | File read cache. Android skips leased entries during eviction; desktop eviction has no lease check | `AndroidFileReadCache.kt`, `AndroidVirtualFileCache.kt` | `DesktopFileReadCache.kt` | A `jvmMain` blob store |
| Medium | Account removal stages and journals | Five `AndroidAccountRemoval*` files | `DesktopAccountRemoval.kt` | A `commonMain` removal state machine |
| Medium | Account removal stages and journals | Four `AndroidAccountRemoval*` files | `DesktopAccountRemoval.kt` | A `commonMain` removal state machine |
| Medium | Pure policy kept in platform code | `AndroidFileSyncContentEvidence.kt` | `DesktopFileSyncRetryPolicy.kt`, `DesktopFileSyncBatchResult.kt`, `DesktopFileSyncContentSlices.kt` | `commonMain` |
| Low | Small identical helpers: streaming multipart body, document-editing capability parsing, bounded reads, `sha256Hex` copies, update-channel preferences | Several files | Several files | `jvmMain` |

Expand Down
13 changes: 13 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ dependencies {
androidTestImplementation("com.squareup.okhttp3:okhttp-tls:5.4.0")
}

// ContractModuleAndroidJsonApiTest compares the contract module's org.json references with the
// platform's own org.json, so unit tests receive the compileSdk boot classpath from the plugin.
// It is read when the test starts, after the plugin has finalized the compile options.
val androidSdkComponents = androidComponents.sdkComponents
tasks.withType<Test>().configureEach {
jvmArgumentProviders.add(
CommandLineArgumentProvider {
val entries = androidSdkComponents.bootClasspath.get().joinToString(File.pathSeparator) { it.asFile.absolutePath }
listOf("-Dnative.androidBootClasspath=$entries")
},
)
}

val validateReleaseSigning by tasks.registering {
group = "verification"
description = "Requires complete protected signing input before producing release artifacts."
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.AppStoreContractRequest
import dev.obiente.nextcloudnative.app.AppStoreContractUnavailableReason
import dev.obiente.nextcloudnative.app.acquireAppStoreContract
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,71 @@ 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", "AppStoreCatalogReleasesKt",
"ContractAcquisitionFailureKt", "ContractAcquisitionOutcomeKt", "JsonEquivalenceKt", "LinkedSourceArchiveKt",
"NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt",
"SignedPackageTarReaderKt", "StaticPhpParametersKt", "StaticRouteContractKt", "StrictJsonValuesKt",
"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(AppStoreContractRequest("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(
AppStoreContractRequest("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,9 @@ 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.AppStoreContractRequest
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 @@ -230,13 +230,10 @@ import dev.obiente.nextcloudnative.app.parseTalkMessageJson
import dev.obiente.nextcloudnative.app.requireSafe
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 @@ -2959,40 +2956,8 @@ internal class AndroidNextcloudServices(
NextcloudApiResponse(response.status, response.body, response.contentType, response.etag, response.location)
}

override suspend fun acquireSignedOpenApiContract(
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
},
)
}
}
override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition =
withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer, ::recordSupportDiagnostic) }

override suspend fun listActivities(
session: NextcloudSession,
Expand Down
Loading
Loading