diff --git a/.github/workflows/android-unit-tests.yml b/.github/workflows/android-unit-tests.yml
index 3a7a9ddcca..7f52af6eeb 100644
--- a/.github/workflows/android-unit-tests.yml
+++ b/.github/workflows/android-unit-tests.yml
@@ -26,4 +26,16 @@ jobs:
run: ./gradlew assembleDebug
- name: Run unit tests
- run: ./gradlew testDebugUnitTest --continue
+ run: ./gradlew testDebugUnitTest :opencloudApp:testOriginalDebugUnitTest --continue
+
+ - name: Generate coverage report
+ run: ./gradlew jacocoAggregatedCoverageVerification
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: jacoco-coverage-report
+ path: |
+ build/reports/jacoco/jacocoAggregatedReport
+ build/reports/jacoco/jacocoAggregatedCoverageVerification
diff --git a/build.gradle b/build.gradle
index 97a4c9a717..9a95bc3ce4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -22,6 +22,7 @@ plugins {
alias libs.plugins.sonarqube
alias libs.plugins.ksp apply false
alias libs.plugins.detekt
+ id 'jacoco'
}
allprojects {
@@ -39,6 +40,118 @@ subprojects {
//apply plugin: "org.jlleitschuh.gradle.ktlint"
//apply plugin: "org.sonarqube"
apply plugin: "io.gitlab.arturbosch.detekt"
+ apply plugin: "jacoco"
+
+ jacoco {
+ toolVersion = "0.8.12"
+ }
+
+ tasks.withType(Test).configureEach {
+ jacoco {
+ includeNoLocationClasses = true
+ excludes = ["jdk.internal.*"]
+ }
+ }
+
+ plugins.withId("com.android.application") {
+ android {
+ buildTypes {
+ debug {
+ enableUnitTestCoverage true
+ }
+ }
+ }
+ }
+
+ plugins.withId("com.android.library") {
+ android {
+ buildTypes {
+ debug {
+ enableUnitTestCoverage true
+ }
+ }
+ }
+ }
+}
+
+def coverageProjects = subprojects.findAll {
+ ["opencloudApp", "opencloudComLibrary", "opencloudData", "opencloudDomain"].contains(it.name)
+}
+
+def coverageExclusions = [
+ '**/R.class',
+ '**/R$*.class',
+ '**/BuildConfig.*',
+ '**/Manifest*.*',
+ '**/*Test*.*',
+ '**/*JsonAdapter.*',
+ '**/*_Impl*.*',
+ '**/*Binding.*',
+ '**/databinding/**',
+]
+
+def coverageClassDirectories = files(coverageProjects.collect { project ->
+ [
+ fileTree(dir: "${project.buildDir}/tmp/kotlin-classes/debug", excludes: coverageExclusions),
+ fileTree(dir: "${project.buildDir}/tmp/kotlin-classes/originalDebug", excludes: coverageExclusions),
+ fileTree(dir: "${project.buildDir}/intermediates/javac/debug/classes", excludes: coverageExclusions),
+ fileTree(dir: "${project.buildDir}/intermediates/javac/originalDebug/classes", excludes: coverageExclusions),
+ ]
+})
+
+def coverageSourceDirectories = files(coverageProjects.collect { project ->
+ [
+ "${project.projectDir}/src/main/java",
+ "${project.projectDir}/src/main/kotlin",
+ ]
+})
+
+def coverageExecutionData = fileTree(rootDir) {
+ include "**/build/outputs/unit_test_code_coverage/**/*.exec"
+ include "**/build/jacoco/*.exec"
+}
+
+tasks.register("jacocoAggregatedReport", JacocoReport) {
+ group = "verification"
+ description = "Generates an aggregated JaCoCo report for JVM unit tests."
+
+ dependsOn(
+ ":opencloudApp:testOriginalDebugUnitTest",
+ ":opencloudComLibrary:testDebugUnitTest",
+ ":opencloudData:testDebugUnitTest",
+ ":opencloudDomain:testDebugUnitTest"
+ )
+
+ reports {
+ xml.required = true
+ html.required = true
+ csv.required = false
+ }
+
+ sourceDirectories.from(coverageSourceDirectories)
+ classDirectories.from(coverageClassDirectories)
+ executionData.from(coverageExecutionData)
+}
+
+tasks.register("jacocoAggregatedCoverageVerification", JacocoCoverageVerification) {
+ group = "verification"
+ description = "Verifies aggregated unit-test line coverage."
+
+ dependsOn("jacocoAggregatedReport")
+
+ sourceDirectories.from(coverageSourceDirectories)
+ classDirectories.from(coverageClassDirectories)
+ executionData.from(coverageExecutionData)
+
+ violationRules {
+ rule {
+ limit {
+ counter = "LINE"
+ value = "COVEREDRATIO"
+ minimum = 0.20
+ }
+ }
+ }
}
//sonarqube {
diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle
index 230d3b2f76..41fa9a9cf2 100644
--- a/opencloudApp/build.gradle
+++ b/opencloudApp/build.gradle
@@ -68,6 +68,9 @@ dependencies {
testImplementation libs.junit4
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.mockk
+ testImplementation libs.androidx.test.core
+ testImplementation 'org.robolectric:robolectric:4.15.1'
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2'
// Instrumented tests
androidTestImplementation project(":opencloudTestUtil")
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt
index c5357260cd..79a0327f1e 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt
@@ -424,5 +424,12 @@ class TusUploadHelper(
private const val MAX_RETRIES = 5
private const val BASE_RETRY_DELAY_MS = 250L
private const val MAX_RETRY_DELAY_MS = 2_000L
+
+ fun shouldAttemptTusUpload(
+ fileSize: Long,
+ tusSupport: OCCapability.TusSupport?,
+ tusUploadUrl: String?,
+ ): Boolean =
+ !tusUploadUrl.isNullOrBlank() || (tusSupport != null && fileSize >= DEFAULT_CHUNK_SIZE)
}
}
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt
index 42f7eadee9..08eedd26e2 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt
@@ -307,14 +307,14 @@ class UploadFileFromContentUriWorker(
)
)
val tusSupport = capabilitiesForAccount?.filesTusSupport
- val supportsTus = tusSupport != null
-
val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank()
- val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE)
+ val shouldTryTus = TusUploadHelper.shouldAttemptTusUpload(
+ fileSize = fileSize,
+ tusSupport = tusSupport,
+ tusUploadUrl = ocTransfer.tusUploadUrl,
+ )
- var attemptedTus = false
if (shouldTryTus) {
- attemptedTus = true
Timber.d(
"Attempting TUS upload (size=%d, threshold=%d, resume=%s)",
fileSize,
@@ -355,7 +355,7 @@ class UploadFileFromContentUriWorker(
"Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)",
fileSize,
TusUploadHelper.DEFAULT_CHUNK_SIZE,
- supportsTus
+ tusSupport != null
)
}
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt
index 91296f8427..7c32bccef1 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt
@@ -259,10 +259,12 @@ class UploadFileFromFileSystemWorker(
)
)
val tusSupport = capabilitiesForAccount?.filesTusSupport
- val supportsTus = tusSupport != null
-
val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank()
- val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE)
+ val shouldTryTus = TusUploadHelper.shouldAttemptTusUpload(
+ fileSize = fileSize,
+ tusSupport = tusSupport,
+ tusUploadUrl = ocTransfer.tusUploadUrl,
+ )
if (shouldTryTus) {
Timber.d(
@@ -309,7 +311,7 @@ class UploadFileFromFileSystemWorker(
"Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)",
fileSize,
TusUploadHelper.DEFAULT_CHUNK_SIZE,
- supportsTus
+ tusSupport != null
)
}
diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt
new file mode 100644
index 0000000000..a9dbea8d19
--- /dev/null
+++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt
@@ -0,0 +1,333 @@
+/**
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 OpenCloud GmbH.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package eu.opencloud.android.presentation.viewmodels
+
+import androidx.lifecycle.MutableLiveData
+import androidx.work.WorkInfo
+import eu.opencloud.android.data.providers.SharedPreferencesProvider
+import eu.opencloud.android.domain.UseCaseResult
+import eu.opencloud.android.domain.appregistry.usecases.GetAppRegistryWhichAllowCreationAsStreamUseCase
+import eu.opencloud.android.domain.automaticuploads.model.FolderBackUpConfiguration
+import eu.opencloud.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
+import eu.opencloud.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
+import eu.opencloud.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase
+import eu.opencloud.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase
+import eu.opencloud.android.domain.files.model.FileListOption
+import eu.opencloud.android.domain.files.usecases.CreateFolderAsyncUseCase
+import eu.opencloud.android.domain.files.usecases.GetFileByIdUseCase
+import eu.opencloud.android.domain.files.usecases.GetFolderContentAsStreamUseCase
+import eu.opencloud.android.domain.files.usecases.SortFilesWithSyncInfoUseCase
+import eu.opencloud.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
+import eu.opencloud.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
+import eu.opencloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase
+import eu.opencloud.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream
+import eu.opencloud.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
+import eu.opencloud.android.presentation.common.UIResult
+import eu.opencloud.android.presentation.files.SortOrder
+import eu.opencloud.android.presentation.files.SortOrder.Companion.PREF_FILE_LIST_SORT_ORDER
+import eu.opencloud.android.presentation.files.SortType
+import eu.opencloud.android.presentation.files.SortType.Companion.PREF_FILE_LIST_SORT_TYPE
+import eu.opencloud.android.presentation.files.filelist.MainFileListViewModel
+import eu.opencloud.android.presentation.files.operations.FileOperation
+import eu.opencloud.android.presentation.files.operations.FileOperationsViewModel
+import eu.opencloud.android.presentation.settings.automaticuploads.SettingsPictureUploadsViewModel
+import eu.opencloud.android.presentation.settings.automaticuploads.SettingsVideoUploadsViewModel
+import eu.opencloud.android.presentation.transfers.TransfersViewModel
+import eu.opencloud.android.providers.ContextProvider
+import eu.opencloud.android.providers.WorkManagerProvider
+import eu.opencloud.android.testutil.OC_ACCOUNT_NAME
+import eu.opencloud.android.testutil.OC_BACKUP
+import eu.opencloud.android.testutil.OC_FOLDER
+import eu.opencloud.android.testutil.OC_FOLDER_WITH_SPACE_ID
+import eu.opencloud.android.testutil.OC_ROOT_FOLDER
+import eu.opencloud.android.testutil.OC_SPACE_PERSONAL
+import eu.opencloud.android.testutil.OC_TRANSFER
+import eu.opencloud.android.testutil.livedata.getEmittedValues
+import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase
+import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+
+@ExperimentalCoroutinesApi
+class KeyAppViewModelsTest : ViewModelTest() {
+
+ private lateinit var contextProvider: ContextProvider
+
+ @Before
+ fun setUp() {
+ contextProvider = mockk(relaxed = true)
+ every { contextProvider.isConnected() } returns true
+ kotlinx.coroutines.Dispatchers.setMain(testCoroutineDispatcher)
+ startKoin {
+ allowOverride(override = true)
+ modules(
+ module {
+ factory { contextProvider }
+ }
+ )
+ }
+ }
+
+ @After
+ override fun tearDown() {
+ stopKoin()
+ super.tearDown()
+ }
+
+ @Test
+ fun transfers_combinesTransfersWithSpacesAndDelegatesUploads() = runTest(testCoroutineDispatcher) {
+ val uploadFilesFromSystemUseCase = mockk()
+ every { uploadFilesFromSystemUseCase(any()) } returns Unit
+ val getAllTransfersAsStreamUseCase = mockk()
+ val getSpacesFromEveryAccountUseCaseAsStream = mockk()
+ val transfer = OC_TRANSFER.copy(id = 7, spaceId = OC_SPACE_PERSONAL.id)
+ every { getAllTransfersAsStreamUseCase(Unit) } returns flowOf(listOf(transfer))
+ every { getSpacesFromEveryAccountUseCaseAsStream(Unit) } returns flowOf(listOf(OC_SPACE_PERSONAL))
+ val workInfosLiveData = MutableLiveData>(emptyList())
+ val workManagerProvider = mockk()
+ every { workManagerProvider.getRunningUploadsWorkInfosLiveData() } returns workInfosLiveData
+
+ val viewModel = TransfersViewModel(
+ uploadFilesFromContentUriUseCase = mockk(relaxed = true),
+ uploadFilesFromSystemUseCase = uploadFilesFromSystemUseCase,
+ cancelUploadUseCase = mockk(relaxed = true),
+ retryUploadFromSystemUseCase = mockk(relaxed = true),
+ retryUploadFromContentUriUseCase = mockk(relaxed = true),
+ retryFailedUploadsForAccountUseCase = mockk(relaxed = true),
+ clearFailedTransfersUseCase = mockk(relaxed = true),
+ retryFailedUploadsUseCase = mockk(relaxed = true),
+ clearSuccessfulTransfersUseCase = mockk(relaxed = true),
+ getAllTransfersAsStreamUseCase = getAllTransfersAsStreamUseCase,
+ cancelDownloadForFileUseCase = mockk(relaxed = true),
+ cancelUploadForFileUseCase = mockk(relaxed = true),
+ cancelUploadsRecursivelyUseCase = mockk(relaxed = true),
+ cancelDownloadsRecursivelyUseCase = mockk(relaxed = true),
+ getSpacesFromEveryAccountUseCaseAsStream = getSpacesFromEveryAccountUseCaseAsStream,
+ coroutinesDispatcherProvider = coroutineDispatcherProvider,
+ workManagerProvider = workManagerProvider,
+ )
+
+ val transferWithSpace = viewModel.transfersWithSpaceStateFlow.first { it.isNotEmpty() }.single()
+ assertEquals(transfer, transferWithSpace.first)
+ assertEquals(OC_SPACE_PERSONAL, transferWithSpace.second)
+
+ viewModel.uploadFilesFromSystem(
+ accountName = OC_ACCOUNT_NAME,
+ listOfLocalPaths = listOf("/tmp/image.jpg"),
+ uploadFolderPath = "/Photos/",
+ spaceId = OC_SPACE_PERSONAL.id,
+ )
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+
+ verify {
+ uploadFilesFromSystemUseCase(
+ UploadFilesFromSystemUseCase.Params(
+ accountName = OC_ACCOUNT_NAME,
+ listOfLocalPaths = listOf("/tmp/image.jpg"),
+ uploadFolderPath = "/Photos/",
+ spaceId = OC_SPACE_PERSONAL.id,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun fileOperations_createFolderEmitsLoadingAndSuccess() {
+ val createFolderAsyncUseCase = mockk()
+ every { createFolderAsyncUseCase(any()) } returns UseCaseResult.Success(Unit)
+ val viewModel = FileOperationsViewModel(
+ createFolderAsyncUseCase = createFolderAsyncUseCase,
+ copyFileUseCase = mockk(relaxed = true),
+ moveFileUseCase = mockk(relaxed = true),
+ removeFileUseCase = mockk(relaxed = true),
+ renameFileUseCase = mockk(relaxed = true),
+ synchronizeFileUseCase = mockk(relaxed = true),
+ synchronizeFolderUseCase = mockk(relaxed = true),
+ createFileWithAppProviderUseCase = mockk(relaxed = true),
+ setFilesAsAvailableOfflineUseCase = mockk(relaxed = true),
+ unsetFilesAsAvailableOfflineUseCase = mockk(relaxed = true),
+ manageDeepLinkUseCase = mockk(relaxed = true),
+ setLastUsageFileUseCase = mockk(relaxed = true),
+ isAnyFileAvailableLocallyAndNotAvailableOfflineUseCase = mockk(relaxed = true),
+ contextProvider = contextProvider,
+ coroutinesDispatcherProvider = coroutineDispatcherProvider,
+ )
+
+ val emittedValues = viewModel.createFolder.getEmittedValues(2) {
+ viewModel.performOperation(FileOperation.CreateFolder("Reports", OC_FOLDER))
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+ }
+
+ assertTrue(emittedValues[0]!!.peekContent() is UIResult.Loading)
+ assertTrue(emittedValues[1]!!.peekContent() is UIResult.Success)
+ verify {
+ createFolderAsyncUseCase(CreateFolderAsyncUseCase.Params("Reports", OC_FOLDER))
+ }
+ }
+
+ @Test
+ fun pictureUploads_updatesWifiOnlyAndSchedulesWorker() {
+ val savePictureUploadsConfigurationUseCase = mockk()
+ val saveParams = slot()
+ every { savePictureUploadsConfigurationUseCase(capture(saveParams)) } returns UseCaseResult.Success(Unit)
+ val getPictureUploadsConfigurationStreamUseCase = mockk()
+ every { getPictureUploadsConfigurationStreamUseCase(Unit) } returns MutableStateFlow(
+ OC_BACKUP.copy(
+ accountName = OC_ACCOUNT_NAME,
+ name = FolderBackUpConfiguration.pictureUploadsName,
+ wifiOnly = true,
+ chargingOnly = true,
+ )
+ )
+ val workManagerProvider = mockk(relaxed = true)
+ val getSpaceByIdForAccountUseCase = mockk()
+ every { getSpaceByIdForAccountUseCase(any()) } returns OC_SPACE_PERSONAL
+
+ val viewModel = SettingsPictureUploadsViewModel(
+ accountProvider = mockk(relaxed = true),
+ savePictureUploadsConfigurationUseCase = savePictureUploadsConfigurationUseCase,
+ getPictureUploadsConfigurationStreamUseCase = getPictureUploadsConfigurationStreamUseCase,
+ resetPictureUploadsUseCase = mockk(relaxed = true),
+ getPersonalSpaceForAccountUseCase = mockk(relaxed = true),
+ getSpaceByIdForAccountUseCase = getSpaceByIdForAccountUseCase,
+ workManagerProvider = workManagerProvider,
+ coroutinesDispatcherProvider = coroutineDispatcherProvider,
+ contextProvider = contextProvider,
+ )
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+
+ viewModel.useWifiOnly(false)
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+ viewModel.schedulePictureUploads()
+
+ val savedConfiguration = saveParams.captured.pictureUploadsConfiguration
+ assertFalse(savedConfiguration.wifiOnly)
+ assertTrue(savedConfiguration.chargingOnly)
+ assertEquals(FolderBackUpConfiguration.pictureUploadsName, savedConfiguration.name)
+ verify { workManagerProvider.enqueueAutomaticUploadsWorker() }
+ }
+
+ @Test
+ fun videoUploads_enablesSelectedAccountInPersonalSpace() {
+ val saveVideoUploadsConfigurationUseCase = mockk()
+ val saveParams = slot()
+ every { saveVideoUploadsConfigurationUseCase(capture(saveParams)) } returns UseCaseResult.Success(Unit)
+ val getVideoUploadsConfigurationStreamUseCase = mockk()
+ every { getVideoUploadsConfigurationStreamUseCase(Unit) } returns flowOf(null)
+ val getPersonalSpaceForAccountUseCase = mockk()
+ every { getPersonalSpaceForAccountUseCase(any()) } returns OC_SPACE_PERSONAL
+
+ val viewModel = SettingsVideoUploadsViewModel(
+ accountProvider = mockk(relaxed = true),
+ saveVideoUploadsConfigurationUseCase = saveVideoUploadsConfigurationUseCase,
+ getVideoUploadsConfigurationStreamUseCase = getVideoUploadsConfigurationStreamUseCase,
+ resetVideoUploadsUseCase = mockk(relaxed = true),
+ getPersonalSpaceForAccountUseCase = getPersonalSpaceForAccountUseCase,
+ getSpaceByIdForAccountUseCase = mockk(relaxed = true),
+ workManagerProvider = mockk(relaxed = true),
+ coroutinesDispatcherProvider = coroutineDispatcherProvider,
+ contextProvider = contextProvider,
+ )
+
+ viewModel.enableVideoUploads(OC_ACCOUNT_NAME)
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+
+ val savedConfiguration = saveParams.captured.videoUploadsConfiguration
+ assertEquals(OC_ACCOUNT_NAME, savedConfiguration.accountName)
+ assertEquals(FolderBackUpConfiguration.videoUploadsName, savedConfiguration.name)
+ assertEquals(OC_SPACE_PERSONAL.id, savedConfiguration.spaceId)
+ }
+
+ @Test
+ fun mainFileList_persistsLayoutSortAndNavigatesById() {
+ val sharedPreferencesProvider = mockk(relaxed = true)
+ every { sharedPreferencesProvider.getBoolean(any(), any()) } returns false
+ every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_TYPE, any()) } returns SortType.SORT_TYPE_BY_NAME.ordinal
+ every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_ORDER, any()) } returns SortOrder.SORT_ORDER_ASCENDING.ordinal
+ val getFileByIdUseCase = mockk()
+ every { getFileByIdUseCase(GetFileByIdUseCase.Params(OC_FOLDER_WITH_SPACE_ID.id!!)) } returns
+ UseCaseResult.Success(OC_FOLDER_WITH_SPACE_ID)
+ val synchronizeFolderUseCase = mockk()
+ every { synchronizeFolderUseCase(any()) } returns UseCaseResult.Success(Unit)
+ val getAppRegistryWhichAllowCreationAsStreamUseCase = mockk()
+ every { getAppRegistryWhichAllowCreationAsStreamUseCase(any()) } returns flowOf(emptyList())
+ val getFolderContentAsStreamUseCase = mockk()
+ every { getFolderContentAsStreamUseCase(any()) } returns flowOf(emptyList())
+ val getSpaceWithSpecialsByIdForAccountUseCase = mockk()
+ every { getSpaceWithSpecialsByIdForAccountUseCase(any()) } returns OC_SPACE_PERSONAL
+
+ val viewModel = MainFileListViewModel(
+ getFolderContentAsStreamUseCase = getFolderContentAsStreamUseCase,
+ getSharedByLinkForAccountAsStreamUseCase = mockk(relaxed = true),
+ getFilesAvailableOfflineFromAccountAsStreamUseCase = mockk(relaxed = true),
+ getFileByIdUseCase = getFileByIdUseCase,
+ getFileByRemotePathUseCase = mockk(relaxed = true),
+ getSpaceWithSpecialsByIdForAccountUseCase = getSpaceWithSpecialsByIdForAccountUseCase,
+ sortFilesWithSyncInfoUseCase = SortFilesWithSyncInfoUseCase(),
+ synchronizeFolderUseCase = synchronizeFolderUseCase,
+ getAppRegistryWhichAllowCreationAsStreamUseCase = getAppRegistryWhichAllowCreationAsStreamUseCase,
+ getAppRegistryForMimeTypeAsStreamUseCase = mockk(relaxed = true),
+ getUrlToOpenInWebUseCase = mockk(relaxed = true),
+ filterFileMenuOptionsUseCase = mockk(relaxed = true),
+ contextProvider = contextProvider,
+ coroutinesDispatcherProvider = coroutineDispatcherProvider,
+ sharedPreferencesProvider = sharedPreferencesProvider,
+ initialFolderToDisplay = OC_ROOT_FOLDER,
+ fileListOptionParam = FileListOption.ALL_FILES,
+ )
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+
+ viewModel.setGridModeAsPreferred()
+ viewModel.updateSortTypeAndOrder(SortType.SORT_TYPE_BY_SIZE, SortOrder.SORT_ORDER_DESCENDING)
+ viewModel.navigateToFolderId(OC_FOLDER_WITH_SPACE_ID.id!!)
+ testCoroutineDispatcher.scheduler.advanceUntilIdle()
+
+ assertEquals(OC_FOLDER_WITH_SPACE_ID, viewModel.getFile())
+ verify { sharedPreferencesProvider.putBoolean("RECYCLER_VIEW_PREFERRED", true) }
+ verify { sharedPreferencesProvider.putInt(PREF_FILE_LIST_SORT_TYPE, SortType.SORT_TYPE_BY_SIZE.ordinal) }
+ verify { sharedPreferencesProvider.putInt(PREF_FILE_LIST_SORT_ORDER, SortOrder.SORT_ORDER_DESCENDING.ordinal) }
+ verify {
+ synchronizeFolderUseCase(
+ SynchronizeFolderUseCase.Params(
+ remotePath = OC_ROOT_FOLDER.remotePath,
+ accountName = OC_ROOT_FOLDER.owner,
+ spaceId = OC_ROOT_FOLDER.spaceId,
+ syncMode = SynchronizeFolderUseCase.SyncFolderMode.SYNC_CONTENTS,
+ )
+ )
+ }
+ }
+}
diff --git a/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt
new file mode 100644
index 0000000000..4c9693c6aa
--- /dev/null
+++ b/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt
@@ -0,0 +1,238 @@
+/**
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 OpenCloud GmbH.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package eu.opencloud.android.workers
+
+import android.net.Uri
+import androidx.test.core.app.ApplicationProvider
+import eu.opencloud.android.domain.capabilities.model.OCCapability
+import eu.opencloud.android.domain.transfers.TransferRepository
+import eu.opencloud.android.lib.common.OpenCloudClient
+import eu.opencloud.android.testutil.OC_TRANSFER
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class TusUploadHelperTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var transferRepository: TransferRepository
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ transferRepository = mockk(relaxed = true)
+ every { transferRepository.updateTusState(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun upload_createsSessionWithFirstChunkAndClearsTusState() {
+ val localFile = tempFileWithBytes(byteArrayOf(1, 2, 3, 4, 5))
+ val uploadUrl = "/uploads/new-session"
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(201)
+ .addHeader("Location", uploadUrl)
+ .addHeader("Upload-Offset", "5")
+ )
+ server.enqueue(MockResponse().setResponseCode(404))
+ val progress = mutableListOf()
+
+ val resultEtag = TusUploadHelper(transferRepository).upload(
+ client = newClient(),
+ transfer = OC_TRANSFER.copy(tusUploadUrl = null, tusUploadChecksum = "sha256:abc"),
+ uploadId = UPLOAD_ID,
+ localPath = localFile.absolutePath,
+ remotePath = "/Photos/image.jpg",
+ fileSize = localFile.length(),
+ mimeType = "image/jpeg",
+ lastModified = "1700000000",
+ tusSupport = tusSupport(),
+ progressListener = null,
+ progressCallback = { offset, _ -> progress += offset },
+ spaceWebDavUrl = server.url("/dav/spaces/personal").toString(),
+ )
+
+ assertNull(resultEtag)
+ assertEquals(listOf(5L), progress)
+
+ val createRequest = server.takeRequest()
+ assertEquals("POST", createRequest.method)
+ assertEquals("/dav/spaces/personal/Photos", createRequest.path)
+ assertEquals("0", createRequest.getHeader("Upload-Offset"))
+ assertEquals("5", createRequest.getHeader("Upload-Length"))
+ assertTrue(createRequest.getHeader("Upload-Metadata")!!.contains("checksum"))
+
+ verify {
+ transferRepository.updateTusState(
+ id = UPLOAD_ID,
+ tusUploadUrl = server.url(uploadUrl).toString(),
+ tusUploadLength = 5,
+ tusUploadMetadata = "filename=image.jpg;mimetype=image/jpeg;mtime=1700000000;checksum=sha256 abc",
+ tusUploadChecksum = "sha256:abc",
+ tusResumableVersion = "1.0.0",
+ tusUploadExpires = null,
+ tusUploadConcat = null,
+ )
+ transferRepository.updateTusState(
+ id = UPLOAD_ID,
+ tusUploadUrl = null,
+ tusUploadLength = null,
+ tusUploadMetadata = null,
+ tusUploadChecksum = null,
+ tusResumableVersion = null,
+ tusUploadExpires = null,
+ tusUploadConcat = null,
+ )
+ }
+ }
+
+ @Test
+ fun upload_resumesExistingSessionFromServerOffset() {
+ val localFile = tempFileWithBytes(byteArrayOf(1, 2, 3, 4, 5))
+ val existingTusUrl = server.url("/uploads/existing-session").toString()
+ server.enqueue(MockResponse().setResponseCode(204).addHeader("Upload-Offset", "2"))
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(204)
+ .addHeader("Upload-Offset", "5")
+ .addHeader("ETag", "\"remote-etag\"")
+ )
+ val progress = mutableListOf()
+
+ val resultEtag = TusUploadHelper(transferRepository).upload(
+ client = newClient(),
+ transfer = OC_TRANSFER.copy(tusUploadUrl = existingTusUrl),
+ uploadId = UPLOAD_ID,
+ localPath = localFile.absolutePath,
+ remotePath = "/Photos/image.jpg",
+ fileSize = localFile.length(),
+ mimeType = "image/jpeg",
+ lastModified = null,
+ tusSupport = tusSupport(maxChunkSize = 3),
+ progressListener = null,
+ progressCallback = { offset, _ -> progress += offset },
+ )
+
+ assertEquals("remote-etag", resultEtag)
+ assertEquals(listOf(2L, 5L), progress)
+
+ val headRequest = server.takeRequest()
+ assertEquals("HEAD", headRequest.method)
+ assertEquals("/uploads/existing-session", headRequest.path)
+
+ val patchRequest = server.takeRequest()
+ assertEquals("PATCH", patchRequest.method)
+ assertEquals("/uploads/existing-session", patchRequest.path)
+ assertEquals("2", patchRequest.getHeader("Upload-Offset"))
+
+ verify(exactly = 1) {
+ transferRepository.updateTusState(
+ id = UPLOAD_ID,
+ tusUploadUrl = null,
+ tusUploadLength = null,
+ tusUploadMetadata = null,
+ tusUploadChecksum = null,
+ tusResumableVersion = null,
+ tusUploadExpires = null,
+ tusUploadConcat = null,
+ )
+ }
+ }
+
+ @Test
+ fun shouldAttemptTusUpload_usesFallbackForSmallFilesWithoutPendingSession() {
+ val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload(
+ fileSize = TusUploadHelper.DEFAULT_CHUNK_SIZE - 1,
+ tusSupport = tusSupport(),
+ tusUploadUrl = null,
+ )
+
+ assertFalse(shouldAttemptTusUpload)
+ }
+
+ @Test
+ fun shouldAttemptTusUpload_usesTusForLargeFilesWithServerSupport() {
+ val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload(
+ fileSize = TusUploadHelper.DEFAULT_CHUNK_SIZE,
+ tusSupport = tusSupport(),
+ tusUploadUrl = null,
+ )
+
+ assertTrue(shouldAttemptTusUpload)
+ }
+
+ @Test
+ fun shouldAttemptTusUpload_resumesPendingSessionsWithoutServerSupport() {
+ val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload(
+ fileSize = 1,
+ tusSupport = null,
+ tusUploadUrl = "https://server.test/uploads/session",
+ )
+
+ assertTrue(shouldAttemptTusUpload)
+ }
+
+ private fun newClient(): OpenCloudClient =
+ OpenCloudClient(
+ Uri.parse(server.url("/").toString().removeSuffix("/")),
+ null,
+ true,
+ null,
+ ApplicationProvider.getApplicationContext()
+ )
+
+ private fun tempFileWithBytes(bytes: ByteArray): File =
+ File.createTempFile("tus-helper", ".bin").apply {
+ writeBytes(bytes)
+ }
+
+ private fun tusSupport(maxChunkSize: Int = 10): OCCapability.TusSupport =
+ OCCapability.TusSupport(
+ version = "1.0.0",
+ resumable = "1.0.0",
+ extension = "creation,creation-with-upload",
+ maxChunkSize = maxChunkSize,
+ httpMethodOverride = null
+ )
+
+ companion object {
+ private const val UPLOAD_ID = 42L
+ }
+}
diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt
new file mode 100644
index 0000000000..f007bfe464
--- /dev/null
+++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt
@@ -0,0 +1,236 @@
+/* openCloud Android Library is available under MIT license
+ *
+ * Copyright (C) 2026 OpenCloud GmbH.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+ * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package eu.opencloud.android.lib.resources.oauth
+
+import android.net.Uri
+import androidx.test.core.app.ApplicationProvider
+import eu.opencloud.android.lib.common.OpenCloudClient
+import eu.opencloud.android.lib.common.http.HttpConstants.AUTHORIZATION_HEADER
+import eu.opencloud.android.lib.resources.oauth.params.ClientRegistrationParams
+import eu.opencloud.android.lib.resources.oauth.params.TokenRequestParams
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.net.URLDecoder
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class OAuthRemoteOperationTest {
+
+ private lateinit var server: MockWebServer
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun tokenRequest_withConfidentialClient_sendsAuthorizationHeaderAndParsesResponse() {
+ server.enqueue(MockResponse().setResponseCode(200).setBody(tokenResponseJson()))
+
+ val result = TokenRequestRemoteOperation(
+ TokenRequestParams.Authorization(
+ tokenEndpoint = server.url("/token").toString(),
+ clientAuth = "Basic abc123",
+ grantType = "authorization_code",
+ scope = "openid profile",
+ clientId = "desktop-client",
+ clientSecret = "client-secret",
+ authorizationCode = "auth-code",
+ redirectUri = "opencloud://oauth",
+ codeVerifier = "verifier"
+ )
+ ).execute(newClient())
+
+ assertTrue(result.isSuccess)
+ assertEquals("access-token", result.data.accessToken)
+ assertEquals("refresh-token", result.data.refreshToken)
+
+ val request = server.takeRequest()
+ assertEquals("POST", request.method)
+ assertEquals("/token", request.path)
+ assertEquals("Basic abc123", request.getHeader(AUTHORIZATION_HEADER))
+
+ val form = request.formBody()
+ assertEquals("authorization_code", form["grant_type"])
+ assertEquals("auth-code", form["code"])
+ assertEquals("opencloud://oauth", form["redirect_uri"])
+ assertEquals("verifier", form["code_verifier"])
+ assertEquals("desktop-client", form["client_id"])
+ assertEquals("client-secret", form["client_secret"])
+ }
+
+ @Test
+ fun tokenRequest_withPublicPkceClient_omitsAuthorizationHeader() {
+ server.enqueue(MockResponse().setResponseCode(200).setBody(tokenResponseJson()))
+
+ val result = TokenRequestRemoteOperation(
+ TokenRequestParams.Authorization(
+ tokenEndpoint = server.url("/token").toString(),
+ clientAuth = "",
+ grantType = "authorization_code",
+ scope = "openid",
+ clientId = "public-client",
+ clientSecret = null,
+ authorizationCode = "auth-code",
+ redirectUri = "opencloud://oauth",
+ codeVerifier = "verifier"
+ )
+ ).execute(newClient())
+
+ assertTrue(result.isSuccess)
+
+ val request = server.takeRequest()
+ assertNull(request.getHeader(AUTHORIZATION_HEADER))
+ assertEquals("public-client", request.formBody()["client_id"])
+ }
+
+ @Test
+ fun tokenRequest_invalidJson_returnsExceptionResult() {
+ server.enqueue(MockResponse().setResponseCode(200).setBody("{ invalid json"))
+
+ val result = TokenRequestRemoteOperation(
+ TokenRequestParams.RefreshToken(
+ tokenEndpoint = server.url("/token").toString(),
+ clientAuth = "",
+ grantType = "refresh_token",
+ scope = "openid",
+ clientId = "public-client",
+ clientSecret = null,
+ refreshToken = "refresh-token"
+ )
+ ).execute(newClient())
+
+ assertFalse(result.isSuccess)
+ assertTrue(result.exception != null)
+ }
+
+ @Test
+ fun registerClient_sendsJsonRequestAndParsesCreatedResponse() {
+ server.enqueue(MockResponse().setResponseCode(201).setBody(clientRegistrationResponseJson()))
+
+ val result = RegisterClientRemoteOperation(
+ ClientRegistrationParams(
+ registrationEndpoint = server.url("/register").toString(),
+ clientName = "OpenCloud Android",
+ redirectUris = listOf("opencloud://oauth"),
+ tokenEndpointAuthMethod = "none",
+ applicationType = "native"
+ )
+ ).execute(newClient())
+
+ assertTrue(result.isSuccess)
+ assertEquals("client-id", result.data.clientId)
+ assertEquals("client-secret", result.data.clientSecret)
+
+ val request = server.takeRequest()
+ assertEquals("POST", request.method)
+ assertEquals("/register", request.path)
+
+ val body = JSONObject(request.body.readUtf8())
+ assertEquals("native", body.getString("application_type"))
+ assertEquals("OpenCloud Android", body.getString("client_name"))
+ assertEquals("none", body.getString("token_endpoint_auth_method"))
+ assertEquals("opencloud://oauth", body.getJSONArray("redirect_uris").getString(0))
+ }
+
+ @Test
+ fun registerClient_unexpectedStatus_returnsUnsuccessfulResult() {
+ server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid_client_metadata"}"""))
+
+ val result = RegisterClientRemoteOperation(
+ ClientRegistrationParams(
+ registrationEndpoint = server.url("/register").toString(),
+ clientName = "OpenCloud Android",
+ redirectUris = listOf("opencloud://oauth"),
+ tokenEndpointAuthMethod = "none",
+ applicationType = "native"
+ )
+ ).execute(newClient())
+
+ assertFalse(result.isSuccess)
+ assertEquals(400, result.httpCode)
+ }
+
+ private fun newClient(): OpenCloudClient =
+ OpenCloudClient(
+ Uri.parse(server.url("/").toString().removeSuffix("/")),
+ null,
+ true,
+ null,
+ ApplicationProvider.getApplicationContext()
+ )
+
+ private fun tokenResponseJson(): String =
+ """
+ {
+ "access_token": "access-token",
+ "expires_in": 3600,
+ "refresh_token": "refresh-token",
+ "token_type": "Bearer",
+ "user_id": "user",
+ "scope": "openid profile",
+ "id_token": "id-token",
+ "additional_parameters": {
+ "server": "opencloud"
+ }
+ }
+ """.trimIndent()
+
+ private fun clientRegistrationResponseJson(): String =
+ """
+ {
+ "client_id": "client-id",
+ "client_secret": "client-secret",
+ "client_id_issued_at": 1700000000,
+ "client_secret_expires_at": 0
+ }
+ """.trimIndent()
+
+ private fun okhttp3.mockwebserver.RecordedRequest.formBody(): Map =
+ body.readUtf8()
+ .split("&")
+ .filter { it.isNotBlank() }
+ .associate { entry ->
+ val parts = entry.split("=", limit = 2)
+ URLDecoder.decode(parts[0], "UTF-8") to URLDecoder.decode(parts.getOrElse(1) { "" }, "UTF-8")
+ }
+}
diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/shares/ShareRemoteOperationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/shares/ShareRemoteOperationTest.kt
new file mode 100644
index 0000000000..36ab3c98a9
--- /dev/null
+++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/shares/ShareRemoteOperationTest.kt
@@ -0,0 +1,187 @@
+/* openCloud Android Library is available under MIT license
+ *
+ * Copyright (C) 2026 OpenCloud GmbH.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+ * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package eu.opencloud.android.lib.resources.shares
+
+import android.net.Uri
+import androidx.test.core.app.ApplicationProvider
+import eu.opencloud.android.lib.common.OpenCloudClient
+import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory
+import eu.opencloud.android.lib.common.http.HttpConstants.CONTENT_TYPE_HEADER
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.net.URLDecoder
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class ShareRemoteOperationTest {
+
+ private lateinit var server: MockWebServer
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun createShare_sendsOcsHeaderAndOptionalFormFields() {
+ server.enqueue(MockResponse().setResponseCode(200).setBody(shareResponseJson(id = "11", permissions = 19)))
+
+ val operation = CreateRemoteShareOperation(
+ remoteFilePath = "/Photos/image.jpg",
+ shareType = ShareType.USER,
+ shareWith = "user@example.com",
+ permissions = 19
+ ).apply {
+ name = "Vacation"
+ password = "secret"
+ expirationDateInMillis = 1_735_689_600_000
+ }
+
+ val result = operation.execute(newClient())
+
+ assertTrue(result.isSuccess)
+ assertEquals("11", result.data.shares.first().id)
+ assertEquals(19, result.data.shares.first().permissions)
+
+ val request = server.takeRequest()
+ assertEquals("POST", request.method)
+ assertEquals("/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json", request.path)
+ assertEquals("true", request.getHeader("OCS-APIREQUEST"))
+ assertEquals("application/x-www-form-urlencoded", request.getHeader(CONTENT_TYPE_HEADER))
+
+ val form = request.formBody()
+ assertEquals("/Photos/image.jpg", form["path"])
+ assertEquals("0", form["shareType"])
+ assertEquals("user@example.com", form["shareWith"])
+ assertEquals("19", form["permissions"])
+ assertEquals("Vacation", form["name"])
+ assertEquals("secret", form["password"])
+ assertEquals("2025-01-01", form["expireDate"])
+ }
+
+ @Test
+ fun updateShare_sendsOnlyRequestedFormFields() {
+ server.enqueue(MockResponse().setResponseCode(200).setBody(shareResponseJson(id = "12", permissions = 1)))
+
+ val operation = UpdateRemoteShareOperation(remoteId = "12").apply {
+ name = ""
+ password = null
+ expirationDateInMillis = -1
+ permissions = 1
+ }
+
+ val result = operation.execute(newClient())
+
+ assertTrue(result.isSuccess)
+
+ val request = server.takeRequest()
+ assertEquals("PUT", request.method)
+ assertEquals("/ocs/v2.php/apps/files_sharing/api/v1/shares/12?format=json", request.path)
+ assertEquals("true", request.getHeader("OCS-APIREQUEST"))
+
+ val form = request.formBody()
+ assertEquals("", form["name"])
+ assertEquals("", form["expireDate"])
+ assertFalse(form.containsKey("password"))
+ assertEquals("1", form["permissions"])
+ }
+
+ @Test
+ fun createShare_returnsUnsuccessfulResultForServerError() {
+ server.enqueue(MockResponse().setResponseCode(500).setBody("server error"))
+
+ val result = CreateRemoteShareOperation(
+ remoteFilePath = "/Photos/image.jpg",
+ shareType = ShareType.USER,
+ shareWith = "user@example.com",
+ permissions = 1
+ ).execute(newClient())
+
+ assertFalse(result.isSuccess)
+ assertEquals(500, result.httpCode)
+ assertNull(result.data)
+ }
+
+ private fun newClient(): OpenCloudClient =
+ OpenCloudClient(
+ Uri.parse(server.url("/").toString().removeSuffix("/")),
+ null,
+ true,
+ null,
+ ApplicationProvider.getApplicationContext()
+ ).apply {
+ credentials = OpenCloudCredentialsFactory.newBearerCredentials("user", "TOKEN")
+ }
+
+ private fun shareResponseJson(id: String, permissions: Int): String =
+ """
+ {
+ "ocs": {
+ "meta": {
+ "status": "ok",
+ "statuscode": 200,
+ "message": null,
+ "itemsperpage": null,
+ "totalitems": null
+ },
+ "data": {
+ "id": "$id",
+ "share_with": "user@example.com",
+ "path": "/Photos/image.jpg",
+ "item_type": "file",
+ "share_with_displayname": "User",
+ "share_type": 0,
+ "permissions": $permissions,
+ "stime": 1700000000
+ }
+ }
+ }
+ """.trimIndent()
+
+ private fun okhttp3.mockwebserver.RecordedRequest.formBody(): Map =
+ body.readUtf8()
+ .split("&")
+ .filter { it.isNotBlank() }
+ .associate { entry ->
+ val parts = entry.split("=", limit = 2)
+ URLDecoder.decode(parts[0], "UTF-8") to URLDecoder.decode(parts.getOrElse(1) { "" }, "UTF-8")
+ }
+}
diff --git a/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB49Test.kt b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB49Test.kt
new file mode 100644
index 0000000000..ff0205a38d
--- /dev/null
+++ b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB49Test.kt
@@ -0,0 +1,85 @@
+/*
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 OpenCloud GmbH.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package eu.opencloud.android.data.roommigrations
+
+import androidx.sqlite.db.SupportSQLiteDatabase
+import androidx.test.filters.SmallTest
+import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME
+import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_REMOTE_ETAG
+import eu.opencloud.android.data.migrations.MIGRATION_48_49
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@SmallTest
+class MigrationToDB49Test : MigrationTest() {
+
+ @Test
+ fun migrationFrom48to49_preservesFilesAndAddsRemoteEtag() {
+ performMigrationTest(
+ previousVersion = 48,
+ currentVersion = 49,
+ insertData = { database -> insertFileToTest(database) },
+ validateMigration = { database -> validateMigrationTo49(database) },
+ listOfMigrations = arrayOf(MIGRATION_48_49)
+ )
+ }
+
+ private fun insertFileToTest(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ "INSERT INTO `$FILES_TABLE_NAME`" +
+ "(" +
+ "owner, " +
+ "remotePath, " +
+ "length, " +
+ "modificationTimestamp, " +
+ "mimeType, " +
+ "needsToUpdateThumbnail, " +
+ "sharedByLink" +
+ ")" +
+ " VALUES " +
+ "(?, ?, ?, ?, ?, ?, ?)",
+ arrayOf(
+ "user@example.com",
+ "/Documents/test.txt",
+ 1024,
+ 1_700_000_000,
+ "text/plain",
+ 0,
+ 0
+ )
+ )
+ }
+
+ private fun validateMigrationTo49(database: SupportSQLiteDatabase) {
+ val cursor = database.query("SELECT * FROM `$FILES_TABLE_NAME`")
+ assertTrue(cursor.moveToFirst())
+
+ val remoteEtagIndex = cursor.getColumnIndex(FILE_REMOTE_ETAG)
+ assertTrue(remoteEtagIndex != -1)
+
+ assertEquals("user@example.com", cursor.getString(cursor.getColumnIndex("owner")))
+ assertEquals("/Documents/test.txt", cursor.getString(cursor.getColumnIndex("remotePath")))
+ assertTrue(cursor.isNull(remoteEtagIndex))
+
+ cursor.close()
+ database.close()
+ }
+}