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
16 changes: 16 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

apply plugin: 'com.android.application'

def testServerUrl = project.findProperty('NC_TEST_SERVER_BASEURL') ?: 'http://10.0.2.2'
def testServerUsername = project.findProperty('NC_TEST_SERVER_USERNAME') ?: 'test'
def testServerPassword = project.findProperty('NC_TEST_SERVER_PASSWORD') ?: 'test'

android {
namespace = 'it.niedermann.owncloud.notes'

Expand All @@ -22,6 +26,11 @@ android {
versionCode 350010001
versionName "35.1.0 Alpha 01"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments = [
TEST_SERVER_URL : testServerUrl.toString(),
TEST_SERVER_USERNAME: testServerUsername.toString(),
TEST_SERVER_PASSWORD: testServerPassword.toString()
]
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
Expand Down Expand Up @@ -194,6 +203,13 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.23.0'
testImplementation 'org.robolectric:robolectric:4.17'

// Instrumented tests
androidTestImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.7.0'
// Json instance for the Retrofit/dav4jvm showcase IT; the converter itself comes transitively
// from com.github.nextcloud:android-library, but it only exposes kotlinx-serialization-core
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
}

configurations.configureEach {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package it.niedermann.owncloud.notes.network

import android.content.Context
import android.net.Uri
import androidx.test.platform.app.InstrumentationRegistry
import com.nextcloud.common.NextcloudClient
import com.owncloud.android.lib.common.OwnCloudBasicCredentials
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.OwnCloudClientFactory
import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation
import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation
import com.owncloud.android.lib.resources.files.model.RemoteFile
import okhttp3.Credentials
import org.junit.After
import org.junit.BeforeClass

/**
* Common base for instrumented tests that talk to a real Nextcloud server, configured via the
* TEST_SERVER_* instrumentation arguments (see app/build.gradle).
* ATTENTION: deletes ALL files of the test user on the server after each test, so you MUST point
* this at a dedicated test account.
*/
abstract class AbstractServerIT {
@After
fun removeAllRemoteFiles() {
val result = ReadFolderRemoteOperation(ROOT_PATH).execute(client)
if (!result.isSuccess) {
return
}

result.data
.filterIsInstance<RemoteFile>()
.filterNot { it.remotePath == ROOT_PATH }
.forEach { RemoveFileRemoteOperation(it.remotePath).execute(client) }
}

companion object {
private const val ROOT_PATH = "/"
private const val FOLLOW_REDIRECTS = true
private const val ARG_TEST_SERVER_URL = "TEST_SERVER_URL"
private const val ARG_TEST_SERVER_USERNAME = "TEST_SERVER_USERNAME"
private const val ARG_TEST_SERVER_PASSWORD = "TEST_SERVER_PASSWORD"

lateinit var context: Context
lateinit var client: OwnCloudClient
lateinit var nextcloudClient: NextcloudClient

@JvmStatic
@BeforeClass
fun connectToTestServer() {
context = InstrumentationRegistry.getInstrumentation().targetContext
val arguments = InstrumentationRegistry.getArguments()
val baseUrl = Uri.parse(requireNotNull(arguments.getString(ARG_TEST_SERVER_URL)))
val username = requireNotNull(arguments.getString(ARG_TEST_SERVER_USERNAME))
val password = requireNotNull(arguments.getString(ARG_TEST_SERVER_PASSWORD))

client = OwnCloudClientFactory.createOwnCloudClient(baseUrl, context, FOLLOW_REDIRECTS).apply {
credentials = OwnCloudBasicCredentials(username, password)
userId = username
}

nextcloudClient = OwnCloudClientFactory.createNextcloudClient(
baseUrl,
username,
Credentials.basic(username, password),
context,
FOLLOW_REDIRECTS
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package it.niedermann.owncloud.notes.network

import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.nextcloud.android.lib.resources.users.UserApi
import com.nextcloud.common.OkHttpMethodBase
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Retrofit
import java.net.HttpURLConnection

/**
* Showcases Retrofit (OCS/JSON) and dav4jvm (WebDAV) side by side. Both reuse the OkHttpClient of
* [nextcloudClient], so TLS, proxy and client-certificate handling stay identical to the existing
* `RemoteOperation`s.
*/
class RetrofitAndDav4jvmIT : AbstractServerIT() {
@Test
fun retrofitFetchesSameUserAsRemoteOperation() {
val userApi = buildRetrofit().create(UserApi::class.java)

val response = userApi.getUser(FORMAT_JSON).execute()

assertTrue("HTTP ${response.code()}", response.isSuccessful)
val user = response.body()?.ocs?.data
assertNotNull(user)

val legacyResult = GetUserInfoRemoteOperation().execute(nextcloudClient)
assertTrue(legacyResult.isSuccess)
assertEquals(legacyResult.resultData.id, user?.id)
assertEquals(legacyResult.resultData.displayName, user?.displayName)
}

@Test
fun retrofitSuspendFunctionReturnsBodyOrThrowsHttpException() {
val userApi = buildRetrofit().create(UserApi::class.java)
val unauthorizedApi = buildRetrofit(Credentials.basic(INVALID_LOGIN, INVALID_LOGIN)).create(UserApi::class.java)

// suspend functions run the call on OkHttp's dispatcher, so no Dispatchers.IO switch is needed
val user = runBlocking { userApi.fetchUser(FORMAT_JSON).ocs.data }
val error = assertThrows(HttpException::class.java) {
runBlocking { unauthorizedApi.fetchUser(FORMAT_JSON) }
}

assertEquals(nextcloudClient.userId, user.id)
assertEquals(HttpURLConnection.HTTP_UNAUTHORIZED, error.code())
}

@Test
fun dav4jvmMkColThenPropfindRootListsFolder() {
val davHttpClient = buildDavOkHttpClient()
val rootUrl = "${nextcloudClient.filesDavUri}/".toHttpUrl()
val root = DavResource(davHttpClient, rootUrl)
val folder = DavResource(davHttpClient, "$rootUrl$FOLDER_NAME/".toHttpUrl())

folder.mkCol(xmlBody = null) { }

val responses = mutableListOf<Pair<Response, Response.HrefRelation>>()
root.propfind(DEPTH_ONE, DisplayName.NAME, ResourceType.NAME) { response, relation ->
responses += response to relation
}

val self = responses.firstOrNull { (_, relation) -> relation == Response.HrefRelation.SELF }?.first
val members = responses.filter { (_, relation) -> relation == Response.HrefRelation.MEMBER }.map { it.first }

assertTrue(self?.get(ResourceType::class.java)?.types?.contains(ResourceType.COLLECTION) == true)
val created = members.firstOrNull { it.hrefName() == FOLDER_NAME }
assertNotNull("$FOLDER_NAME missing in PROPFIND result", created)
assertTrue(created?.get(ResourceType::class.java)?.types?.contains(ResourceType.COLLECTION) == true)
}

private fun buildRetrofit(credentials: String = nextcloudClient.credentials): Retrofit = Retrofit
.Builder()
.baseUrl("${nextcloudClient.baseUri}/")
.client(authenticatedOkHttpClient(credentials))
.addConverterFactory(json.asConverterFactory(JSON_MEDIA_TYPE.toMediaType()))
.build()

private fun authenticatedOkHttpClient(credentials: String): OkHttpClient = nextcloudClient.client
.newBuilder()
.addInterceptor { chain ->
val request =
chain
.request()
.newBuilder()
.header(OkHttpMethodBase.AUTHORIZATION, credentials)
.header(OkHttpMethodBase.USER_AGENT, OwnCloudClientManagerFactory.getUserAgent())
.header(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE)
.build()
chain.proceed(request)
}.build()

// dav4jvm handles redirects itself and requires the underlying client not to follow them
private fun buildDavOkHttpClient(): OkHttpClient = nextcloudClient.client
.newBuilder()
.followRedirects(false)
.addInterceptor { chain ->
val request =
chain
.request()
.newBuilder()
.header(OkHttpMethodBase.AUTHORIZATION, nextcloudClient.credentials)
.header(OkHttpMethodBase.USER_AGENT, OwnCloudClientManagerFactory.getUserAgent())
.build()
chain.proceed(request)
}.build()

companion object {
private const val FORMAT_JSON = "json"
private const val JSON_MEDIA_TYPE = "application/json"
private const val FOLDER_NAME = "notes-dav4jvmShowcase"
private const val DEPTH_ONE = 1
private const val INVALID_LOGIN = "invalid"

private val json = Json { ignoreUnknownKeys = true }
}
}
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ buildscript {
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:9.4.1'
classpath 'com.android.tools.build:gradle:9.4.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"

// NOTE: Do not place your application dependencies here; they belong
Expand Down
9 changes: 9 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
# SPDX-FileCopyrightText: 2015-2024 Stefan Niedermann <info@niedermann.it>
# SPDX-FileCopyrightText: 2024 Andy Scherzinger <info@andy-scherzinger.de>
# SPDX-License-Identifier: GPL-3.0-or-later

# can be overridden by ~/.gradle/gradle.properties
NC_TEST_SERVER_BASEURL=http://10.0.2.2
NC_TEST_SERVER_USERNAME=test
NC_TEST_SERVER_PASSWORD=test

android.useAndroidX=true
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
org.gradle.dependency.verification.console=verbose
Expand All @@ -18,3 +24,6 @@ android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false


# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true
9 changes: 9 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
include ':app'

/*
includeBuild("../android-library") {
dependencySubstitution {
substitute(module("com.github.nextcloud:android-library"))
.using(project(":library"))
}
}
*/
Loading