Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
935281f
feat(dynamic-ui): complete native Pantry workflows
veryCrunchy Jul 30, 2026
419aefa
docs(changelog): link Pantry pull request
veryCrunchy Jul 30, 2026
e2e7bd4
test(visual): refresh dynamic UI captures
veryCrunchy Jul 30, 2026
bc460a4
fix(dynamic-ui): reconcile uncertain mutations
veryCrunchy Jul 30, 2026
670c0ca
fix(dynamic-ui): harden generic write workflows
veryCrunchy Jul 30, 2026
9315c59
test(visual): cover generic dynamic workflows
veryCrunchy Jul 30, 2026
313dc51
fix(dynamic-ui): close contract mutation safety gaps
veryCrunchy Jul 30, 2026
1bba97a
test(visual): keep dynamic QA capture publishable
veryCrunchy Jul 30, 2026
feb7d87
fix(dynamic-ui): reconcile remaining uncertain mutations
veryCrunchy Jul 30, 2026
495d103
perf(dynamic-ui): virtualize collection navigation
veryCrunchy Jul 30, 2026
ebaaa5a
fix(dynamic-ui): preserve mutation and relation recovery
veryCrunchy Jul 30, 2026
e88bfb4
fix(dynamic-ui): harden reviewed write semantics
veryCrunchy Jul 30, 2026
0a75415
feat(dynamic-ui): polish semantic collection workflows
veryCrunchy Jul 31, 2026
ae0bc08
chore(visuals): refresh dynamic UI captures
veryCrunchy Jul 31, 2026
0b292f4
fix(dynamic-ui): require record mutation authority
veryCrunchy Jul 31, 2026
25d7495
fix(dynamic-ui): harden batch and upload recovery
veryCrunchy Jul 31, 2026
f8db8b3
fix(dynamic-ui): close lifecycle and authority gaps
veryCrunchy Jul 31, 2026
43d187b
fix(dynamic-ui): preserve exact editor state
veryCrunchy Jul 31, 2026
5ba9bc4
chore(visuals): refresh capture source digest
veryCrunchy Jul 31, 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
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import org.json.JSONObject

internal object SessionTestBootstrap {
private const val IMPORT_FILENAME = "nc-native-test-session.json"
private const val PREFERENCES_NAME = "nextcloud_native"
private const val WRITE_SCOPE_IMPORT_FILENAME = "nc-native-test-write-scope.json"
private const val KEY_SESSION = "encrypted_session"
private const val KEY_TEST_READ_ONLY = "emulator_test_read_only"

fun importIfPresent(context: Context) {
importSessionIfPresent(context)
importWriteScopeIfPresent(context)
}

private fun importSessionIfPresent(context: Context) {
val importFile = File(context.filesDir, IMPORT_FILENAME)
if (!importFile.isFile) return

Expand All @@ -28,10 +32,12 @@ internal object SessionTestBootstrap {
.let(SessionCipher()::encrypt)

check(
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
context.getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_SESSION, encryptedSession)
.putBoolean(KEY_TEST_READ_ONLY, true)
.remove(KEY_TEST_WRITE_SCOPE_SERVER)
.remove(KEY_TEST_WRITE_SCOPE_PATH)
.commit(),
) {
"Could not store the read-only emulator session."
Expand All @@ -43,6 +49,51 @@ internal object SessionTestBootstrap {
}
}

private fun importWriteScopeIfPresent(context: Context) {
val importFile = File(context.filesDir, WRITE_SCOPE_IMPORT_FILENAME)
if (!importFile.isFile) return
try {
val source = JSONObject(importFile.readText())
val preferences = context.getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE)
if (source.optBoolean("clear", false)) {
check(
preferences.edit()
.remove(KEY_TEST_WRITE_SCOPE_SERVER)
.remove(KEY_TEST_WRITE_SCOPE_PATH)
.commit(),
) {
"Could not clear the emulator write scope."
}
return
}
require(preferences.getBoolean(KEY_TEST_READ_ONLY, false)) {
"A write scope requires the imported read-only emulator session."
}
val encryptedSession = requireNotNull(preferences.getString(KEY_SESSION, null)) {
"The imported emulator session is missing."
}
val serverUrl = JSONObject(SessionCipher().decrypt(encryptedSession))
.getString("serverUrl")
.validatedServerUrl()
val apiPathPrefix = source.getString("apiPathPrefix")
requireNotNull(ScopedTestWriteAuthorization.create(serverUrl, apiPathPrefix)) {
"The emulator write scope is invalid."
}
check(
preferences.edit()
.putString(KEY_TEST_WRITE_SCOPE_SERVER, serverUrl)
.putString(KEY_TEST_WRITE_SCOPE_PATH, apiPathPrefix.trim().trimEnd('/'))
.commit(),
) {
"Could not store the emulator write scope."
}
} finally {
check(importFile.delete() || !importFile.exists()) {
"Could not remove the temporary emulator write scope."
}
}
}

private fun String.validatedServerUrl(): String {
val normalized = trim().trimEnd('/')
val uri = URI(normalized)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
Expand All @@ -182,6 +184,40 @@ import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject

internal fun resolveAndroidNextcloudRedirectLocation(
requestUrl: HttpUrl,
serverUrl: String,
location: String?,
): String? {
val target = location?.let(requestUrl::resolve) ?: return null
if (target.fragment != null) return null
val account = serverUrl.toHttpUrlOrNull() ?: return null
if (
target.scheme != account.scheme ||
target.host != account.host ||
target.port != account.port
) {
return null
}
val accountPath = account.encodedPath.trimEnd('/').takeUnless { it == "/" }.orEmpty()
if (
accountPath.isNotEmpty() &&
target.encodedPath != accountPath &&
!target.encodedPath.startsWith("$accountPath/")
) {
return null
}
val relativePath = target.encodedPath.removePrefix(accountPath)
if (!relativePath.startsWith('/') || relativePath.startsWith("//")) return null
return buildString {
append(relativePath)
target.encodedQuery?.let { query ->
append('?')
append(query)
}
}
}

internal suspend fun executeAndroidDynamicApiGet(
accountId: String,
requestIdentity: String,
Expand Down Expand Up @@ -1562,6 +1598,12 @@ internal class AndroidNextcloudServices(
request: NextcloudApiRequest,
): NextcloudApiResponse = withContext(Dispatchers.IO) {
val safeRequest = request.requireSafe()
safeRequest.multipartBody?.let { multipart ->
return@withContext executeNextcloudMultipartUpload(
session,
multipart.toUploadRequest(safeRequest),
)
}
val accountId = NextcloudDocumentIds.cacheAccountId(session)
val cacheIdentity = safeRequest.dynamicReadCacheIdentity()
if (safeRequest.method != dev.obiente.nextcloudnative.app.NextcloudApiMethod.GET) {
Expand Down Expand Up @@ -2198,10 +2240,7 @@ internal class AndroidNextcloudServices(
client: OkHttpClient = httpClient,
streamingBody: RequestBody? = null,
): HttpResponse {
check(
!appContext.isReadOnlyTestMode() ||
method.isReadOnlyTestRequestMethod(),
) {
check(appContext.isAllowedTestRequest(method, url)) {
"This emulator is using a shared read-only test session. Cloud changes are blocked."
}
val requestBody = when {
Expand Down Expand Up @@ -2235,7 +2274,15 @@ internal class AndroidNextcloudServices(
body = responseBody.byteStream().readBounded(readLimit),
contentType = responseBody.contentType()?.toString(),
etag = response.header("ETag") ?: response.header("OC-Etag"),
location = response.header("Location"),
location = if (session == null) {
response.header("Location")
} else {
resolveAndroidNextcloudRedirectLocation(
requestUrl = response.request.url,
serverUrl = session.serverUrl,
location = response.header("Location"),
)
},
chatLastGiven = response.header("X-Chat-Last-Given"),
contentRange = response.header("Content-Range"),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,120 @@
package dev.obiente.nextcloudnative

import android.content.Context
import java.net.URI
import java.util.Locale

private const val PREFERENCES_NAME = "nextcloud_native"
private const val KEY_TEST_READ_ONLY = "emulator_test_read_only"
internal const val TEST_PREFERENCES_NAME = "nextcloud_native"
internal const val KEY_TEST_READ_ONLY = "emulator_test_read_only"
internal const val KEY_TEST_WRITE_SCOPE_SERVER = "emulator_test_write_scope_server"
internal const val KEY_TEST_WRITE_SCOPE_PATH = "emulator_test_write_scope_path"

internal fun Context.isReadOnlyTestMode(): Boolean =
getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE)
.getBoolean(KEY_TEST_READ_ONLY, false)

internal fun Context.cloudMutationGate(): () -> Boolean = {
!isReadOnlyTestMode()
}

internal fun Context.isAllowedTestRequest(method: String, url: String): Boolean {
if (!isReadOnlyTestMode() || method.isReadOnlyTestRequestMethod()) return true
if (!BuildConfig.DEBUG) return false
val preferences = getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE)
val serverUrl = preferences.getString(KEY_TEST_WRITE_SCOPE_SERVER, null) ?: return false
val apiPathPrefix = preferences.getString(KEY_TEST_WRITE_SCOPE_PATH, null) ?: return false
return ScopedTestWriteAuthorization.create(serverUrl, apiPathPrefix)
?.allows(method, url) == true
}

internal class ScopedTestWriteAuthorization private constructor(
private val scheme: String,
private val host: String,
private val port: Int,
private val absolutePathPrefix: String,
) {
fun allows(method: String, url: String): Boolean {
if (method.uppercase(Locale.ROOT) !in SCOPED_TEST_MUTATION_METHODS) return false
val target = runCatching { URI(url) }.getOrNull() ?: return false
if (
target.scheme?.lowercase(Locale.ROOT) != scheme ||
target.host?.lowercase(Locale.ROOT) != host ||
target.effectivePort() != port ||
target.userInfo != null ||
target.fragment != null
) {
return false
}
val path = target.rawPath ?: return false
if (path.contains('%') || path.contains('\\') || path.contains("//")) return false
return path == absolutePathPrefix || path.startsWith("$absolutePathPrefix/")
}

companion object {
fun create(serverUrl: String, apiPathPrefix: String): ScopedTestWriteAuthorization? {
val server = runCatching { URI(serverUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
val scheme = server.scheme?.lowercase(Locale.ROOT)
val host = server.host?.lowercase(Locale.ROOT)
if (
scheme != "https" ||
host.isNullOrBlank() ||
server.userInfo != null ||
server.query != null ||
server.fragment != null
) {
return null
}
val normalizedApiPrefix = apiPathPrefix.trim().trimEnd('/')
if (!normalizedApiPrefix.isSafeScopedTestApiPrefix()) return null
val serverPath = server.rawPath.orEmpty().trimEnd('/')
if (!serverPath.isSafeServerBasePath()) return null
return ScopedTestWriteAuthorization(
scheme = scheme,
host = host,
port = server.effectivePort(),
absolutePathPrefix = "$serverPath$normalizedApiPrefix",
)
}
}
}

private fun String.isSafeScopedTestApiPrefix(): Boolean {
if (
!startsWith("/ocs/v2.php/apps/") ||
contains('\\') ||
contains('?') ||
contains('#') ||
contains("//")
) {
return false
}
val segments = split('/').filter(String::isNotEmpty)
if (segments.size < 7 || segments.getOrNull(4) != "api") return false
return segments.all { segment ->
segment !in setOf(".", "..") &&
segment.isNotEmpty() &&
segment.all { character ->
character.isLetterOrDigit() || character in "._~-"
}
}
}

private fun String.isSafeServerBasePath(): Boolean =
isEmpty() ||
(
startsWith('/') &&
!contains('%') &&
!contains('\\') &&
!contains("//") &&
split('/').filter(String::isNotEmpty).all { segment ->
segment !in setOf(".", "..")
}
)

private fun URI.effectivePort(): Int = when {
port >= 0 -> port
scheme.equals("https", ignoreCase = true) -> 443
else -> -1
}

private val SCOPED_TEST_MUTATION_METHODS = setOf("POST", "PUT", "PATCH", "DELETE")
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package dev.obiente.nextcloudnative

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import okhttp3.HttpUrl.Companion.toHttpUrl

class AndroidNextcloudRedirectTest {
@Test
fun `authenticated redirects resolve inside the account origin and base path`() {
val serverUrl = "https://cloud.example.test/nextcloud"
val requestUrl = "$serverUrl/apps/music/api/artwork/current".toHttpUrl()

assertEquals(
"/apps/music/api/covers/7?size=large",
resolveAndroidNextcloudRedirectLocation(
requestUrl,
serverUrl,
"../covers/7?size=large",
),
)
assertEquals(
"/apps/music/api/covers/8",
resolveAndroidNextcloudRedirectLocation(
requestUrl,
serverUrl,
"$serverUrl/apps/music/api/covers/8",
),
)
assertNull(
resolveAndroidNextcloudRedirectLocation(
requestUrl,
serverUrl,
"https://other.example.test/nextcloud/apps/music/api/covers/9",
),
)
assertNull(
resolveAndroidNextcloudRedirectLocation(
requestUrl,
serverUrl,
"https://cloud.example.test/apps/music/api/covers/9",
),
)
}
}
Loading
Loading