diff --git a/app/build.gradle b/app/build.gradle index a3a8c0c63..23822e066 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' @@ -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()] @@ -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 { diff --git a/app/src/androidTest/java/it/niedermann/owncloud/notes/network/AbstractServerIT.kt b/app/src/androidTest/java/it/niedermann/owncloud/notes/network/AbstractServerIT.kt new file mode 100644 index 000000000..253081571 --- /dev/null +++ b/app/src/androidTest/java/it/niedermann/owncloud/notes/network/AbstractServerIT.kt @@ -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() + .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 + ) + } + } +} diff --git a/app/src/androidTest/java/it/niedermann/owncloud/notes/network/RetrofitAndDav4jvmIT.kt b/app/src/androidTest/java/it/niedermann/owncloud/notes/network/RetrofitAndDav4jvmIT.kt new file mode 100644 index 000000000..50b41945f --- /dev/null +++ b/app/src/androidTest/java/it/niedermann/owncloud/notes/network/RetrofitAndDav4jvmIT.kt @@ -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>() + 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 } + } +} diff --git a/build.gradle b/build.gradle index 8161364ba..991efb098 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/gradle.properties b/gradle.properties index 4619f216c..3299c07aa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,12 @@ # SPDX-FileCopyrightText: 2015-2024 Stefan Niedermann # SPDX-FileCopyrightText: 2024 Andy Scherzinger # 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 @@ -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 diff --git a/settings.gradle b/settings.gradle index 378adcfc2..6529f644a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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")) + } +} +*/