diff --git a/app/build.gradle.kts b/app/build.gradle.kts index af36c934194b..8fd56c295fd5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -236,6 +236,9 @@ kapt.useBuildCache = true ksp.arg("room.schemaLocation", "$projectDir/schemas") +// Configure KSP for test variants +ksp.arg("dagger.moduleName", project.name) + kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_21) spotless.kotlin { @@ -419,6 +422,7 @@ dependencies { // endregion // region AppScan, document scanner not available on FDroid (generic) due to OpenCV binaries + // To enable the feature for another variant, add it here. "gplayImplementation"(project(":appscan")) "huaweiImplementation"(project(":appscan")) "qaImplementation"(project(":appscan")) @@ -435,6 +439,7 @@ dependencies { implementation(libs.dagger.android.support) ksp(libs.dagger.compiler) ksp(libs.dagger.processor) + kspAndroidTest(libs.dagger.compiler) // endregion // region Crypto diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt index 41631d269609..ee62b6ffe416 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt @@ -434,6 +434,8 @@ class DialogFragmentIT : AbstractIT() { override fun newPresentation() = Unit override fun directCameraUpload() = Unit override fun scanDocUpload() = Unit + override fun scanDocUploadFromApp() = Unit + override fun isScanDocUploadFromAppAvailable(): Boolean = false override fun showTemplate(creator: Creator?, headline: String?) = Unit override fun createRichWorkspace() = Unit } diff --git a/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt b/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt new file mode 100644 index 000000000000..ed7d07d4db34 --- /dev/null +++ b/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.di + +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import dagger.Component +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit test for [VariantModule] that tests the reflection-based approach + * to conditionally load the ScanPageContract. + */ +class VariantModuleTest { + + private lateinit var component: TestVariantComponent + + @Before + fun setup() { + component = DaggerVariantModuleTest_TestVariantComponent.create() + } + + @Test + fun testAppScanWhenNotAvailableShouldReturnError() { + val feature = component.appScanOptionalFeature() + + assertFalse(feature.isAvailable) + assertEquals(AppScanOptionalFeature.Stub, feature) + + try { + feature.getScanContract() + throw AssertionError("Expected UnsupportedOperationException") + } catch (e: UnsupportedOperationException) { + assertTrue(e.message?.contains("not available") == true) + } + } + + @Component(modules = [VariantModule::class]) + interface TestVariantComponent { + fun appScanOptionalFeature(): AppScanOptionalFeature + } +} diff --git a/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt b/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt new file mode 100644 index 000000000000..3ac80ebe9d6c --- /dev/null +++ b/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.di + +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import dagger.Component +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit test for [VariantModule] that tests the reflection-based approach + * to conditionally load the ScanPageContract. + */ +class VariantModuleTest { + + private lateinit var component: TestVariantComponent + + @Before + fun setup() { + component = DaggerVariantModuleTest_TestVariantComponent.create() + } + + @Test + fun testAppScanWhenAvailableShouldReturnContract() { + val feature = component.appScanOptionalFeature() + + assertTrue(feature.isAvailable) + assertNotEquals(AppScanOptionalFeature.Stub, feature) + + assertNotNull(feature.getScanContract()) + } + + @Component(modules = [VariantModule::class]) + interface TestVariantComponent { + fun appScanOptionalFeature(): AppScanOptionalFeature + } +} diff --git a/app/src/generic/java/com/nextcloud/client/di/VariantModule.kt b/app/src/generic/java/com/nextcloud/client/di/VariantModule.kt deleted file mode 100644 index d73f39e243d3..000000000000 --- a/app/src/generic/java/com/nextcloud/client/di/VariantModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Álvaro Brey - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.di - -import com.nextcloud.client.documentscan.AppScanOptionalFeature -import dagger.Module -import dagger.Provides -import dagger.Reusable - -@Module -internal class VariantModule { - @Provides - @Reusable - fun scanOptionalFeature(): AppScanOptionalFeature = AppScanOptionalFeature.Stub -} diff --git a/app/src/gplay/java/com/nextcloud/client/di/VariantModule.kt b/app/src/gplay/java/com/nextcloud/client/di/VariantModule.kt deleted file mode 100644 index 627cb92a06ef..000000000000 --- a/app/src/gplay/java/com/nextcloud/client/di/VariantModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Álvaro Brey - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.di - -import com.nextcloud.appscan.ScanPageContract -import com.nextcloud.client.documentscan.AppScanOptionalFeature -import dagger.Module -import dagger.Provides -import dagger.Reusable - -@Module -internal class VariantModule { - @Provides - @Reusable - fun scanOptionalFeature(): AppScanOptionalFeature = object : AppScanOptionalFeature() { - override fun getScanContract() = ScanPageContract() - } -} diff --git a/app/src/huawei/java/com/nextcloud/client/di/VariantModule.kt b/app/src/huawei/java/com/nextcloud/client/di/VariantModule.kt deleted file mode 100644 index 627cb92a06ef..000000000000 --- a/app/src/huawei/java/com/nextcloud/client/di/VariantModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Álvaro Brey - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.di - -import com.nextcloud.appscan.ScanPageContract -import com.nextcloud.client.documentscan.AppScanOptionalFeature -import dagger.Module -import dagger.Provides -import dagger.Reusable - -@Module -internal class VariantModule { - @Provides - @Reusable - fun scanOptionalFeature(): AppScanOptionalFeature = object : AppScanOptionalFeature() { - override fun getScanContract() = ScanPageContract() - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 510fcb26a49d..7401f14ac3a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,6 +74,7 @@ + diff --git a/app/src/main/java/com/nextcloud/client/di/VariantModule.kt b/app/src/main/java/com/nextcloud/client/di/VariantModule.kt new file mode 100644 index 000000000000..78899be6aec7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/VariantModule.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import androidx.activity.result.contract.ActivityResultContract +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import dagger.Module +import dagger.Provides +import dagger.Reusable + +@Module +internal class VariantModule { + /** + * Using reflection to determine whether the ScanPageContract class from the appscan project is available. + * If yes, an instance of it is returned. If not, a stub is returned indicating the feature is not available. + * + * To make it available for your specific variant, make sure it is included in your build.gradle, + * e.g.: `"qaImplementation"(project(":appscan"))` + */ + @Provides + @Reusable + fun scanOptionalFeature(): AppScanOptionalFeature = try { + // Try to load the ScanPageContract class only if the appscan project is present + val clazz = Class.forName("com.nextcloud.appscan.ScanPageContract") + + @Suppress("UNCHECKED_CAST") + val contractInstance = + clazz.getDeclaredConstructor().newInstance() as ActivityResultContract + object : AppScanOptionalFeature() { + override fun getScanContract(): ActivityResultContract = contractInstance + } + } catch (_: ClassNotFoundException) { + // appscan module is not present in this variant + AppScanOptionalFeature.Stub + } catch (_: Exception) { + // Any reflection/instantiation error -> be safe and use stub + AppScanOptionalFeature.Stub + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 2925b1ba3b62..21a74acb64bf 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -45,6 +45,7 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView +import androidx.core.util.Function import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -157,10 +158,12 @@ import com.owncloud.android.utils.PermissionUtil.requestNotificationPermission import com.owncloud.android.utils.PermissionUtil.requestStoragePermissionIfNeeded import com.owncloud.android.utils.PushUtils import com.owncloud.android.utils.StringUtils +import com.owncloud.android.utils.UriUtils import com.owncloud.android.utils.theme.CapabilityUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.apache.commons.io.FilenameUtils import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -971,10 +974,13 @@ class FileDisplayActivity : */ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (data != null && - requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS && + ( + requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS || + requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME + ) && (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE) ) { - requestUploadOfContentFromApps(data, resultCode) + requestUploadOfContentFromApps(requestCode, resultCode, data) } else if (data != null && requestCode == REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM && ( @@ -1108,7 +1114,7 @@ class FileDisplayActivity : } } - private fun requestUploadOfContentFromApps(contentIntent: Intent, resultCode: Int) { + private fun requestUploadOfContentFromApps(requestCode: Int, resultCode: Int, contentIntent: Intent) { val streamsToUpload = ArrayList() if (contentIntent.clipData != null && (contentIntent.clipData?.itemCount ?: 0) > 0) { @@ -1130,6 +1136,17 @@ class FileDisplayActivity : val currentDir = getCurrentDir() val remotePath = if (currentDir != null) currentDir.remotePath else OCFile.ROOT_PATH + var fileDisplayNameTransformer: Function? = null + if (requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME) { + fileDisplayNameTransformer = { uri: Uri -> + val displayName = UriUtils.getDisplayNameForUri(uri, applicationContext) + if (displayName != null && displayName.isNotEmpty()) { + FileOperationsHelper.getTimestampedFileName("." + FilenameUtils.getExtension(displayName)) + } else { + null + } + } + } val uploader = UriUploader( this, @@ -1140,7 +1157,8 @@ class FileDisplayActivity : ), behaviour, false, // Not show waiting dialog while file is being copied from private storage - null // Not needed copy temp task listener + null, // Not needed copy temp task listener + fileDisplayNameTransformer ) uploader.uploadUris() @@ -3105,6 +3123,9 @@ class FileDisplayActivity : @JvmField val REQUEST_CODE__UPLOAD_FROM_VIDEO_CAMERA: Int = REQUEST_CODE__LAST_SHARED + 6 + @JvmField + val REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME: Int = REQUEST_CODE__LAST_SHARED + 7 + protected val DELAY_TO_REQUEST_REFRESH_OPERATION_LATER: Long = DELAY_TO_REQUEST_OPERATIONS_LATER + 350 private val TAG: String = FileDisplayActivity::class.java.getSimpleName() diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.java index c28f1e9837f9..0343cb17a292 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.java @@ -53,6 +53,16 @@ public interface OCFileListBottomSheetActions { */ void scanDocUpload(); + /** + * Offers scanning a document in a supported external app and then upload to the current folder. + */ + void scanDocUploadFromApp(); + + /** + * @return true, if a supported external app is available for {@link #scanDocUploadFromApp()} + */ + boolean isScanDocUploadFromAppAvailable(); + /** * open template selection for creator @link Creator */ diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt index d67689722a45..4691e383a2f2 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt @@ -204,6 +204,11 @@ class OCFileListBottomSheetDialog( actions.scanDocUpload() dismiss() } + } else if (actions.isScanDocUploadFromAppAvailable) { + menuScanDocUpload.setOnClickListener { + actions.scanDocUploadFromApp() + dismiss() + } } else { menuScanDocUpload.visibility = View.GONE } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 151c2305fb38..65ef422fa234 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Philipp Hasper * SPDX-FileCopyrightText: 2023 TSI-mc * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Álvaro Brey @@ -228,6 +229,8 @@ public class OCFileListFragment extends ExtendedListFragment implements private FloatingActionButton mFabMain; public static boolean isMultipleFileSelectedForCopyOrMove = false; + private static final Intent scanIntentExternalApp = new Intent("org.fairscan.app.action.SCAN_TO_PDF"); + @Inject DeviceInfo deviceInfo; protected enum MenuItemAddRemove { @@ -590,6 +593,22 @@ public void scanDocUpload() { } } + @Override + public void scanDocUploadFromApp() { + requireActivity().startActivityForResult( + scanIntentExternalApp, + FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME); + } + + @Override + public boolean isScanDocUploadFromAppAvailable() { + var context = getActivity(); + if (context == null) { + return false; + } + return scanIntentExternalApp.resolveActivity(context.getPackageManager()) != null; + } + @Override public void uploadFiles() { if (!(getActivity() instanceof FileActivity fileActivity)) { diff --git a/app/src/qa/java/com/nextcloud/client/di/VariantModule.kt b/app/src/qa/java/com/nextcloud/client/di/VariantModule.kt deleted file mode 100644 index 627cb92a06ef..000000000000 --- a/app/src/qa/java/com/nextcloud/client/di/VariantModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Álvaro Brey - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.di - -import com.nextcloud.appscan.ScanPageContract -import com.nextcloud.client.documentscan.AppScanOptionalFeature -import dagger.Module -import dagger.Provides -import dagger.Reusable - -@Module -internal class VariantModule { - @Provides - @Reusable - fun scanOptionalFeature(): AppScanOptionalFeature = object : AppScanOptionalFeature() { - override fun getScanContract() = ScanPageContract() - } -} diff --git a/app/src/versionDev/java/com/nextcloud/client/di/VariantModule.kt b/app/src/versionDev/java/com/nextcloud/client/di/VariantModule.kt deleted file mode 100644 index d73f39e243d3..000000000000 --- a/app/src/versionDev/java/com/nextcloud/client/di/VariantModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Álvaro Brey - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.di - -import com.nextcloud.client.documentscan.AppScanOptionalFeature -import dagger.Module -import dagger.Provides -import dagger.Reusable - -@Module -internal class VariantModule { - @Provides - @Reusable - fun scanOptionalFeature(): AppScanOptionalFeature = AppScanOptionalFeature.Stub -} diff --git a/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt b/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt index 65a30f79500e..2170bc0be9b6 100644 --- a/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt +++ b/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt @@ -5,13 +5,14 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ -package com.nextcloud.appscan +package com.nextcloud.appscan // Note: if class package or name changes, you must adjust the app's VariantModule.kt import android.app.Activity import android.content.Context import android.content.Intent import androidx.activity.result.contract.ActivityResultContract +@Suppress("unused") // Class is instantiated via reflection class ScanPageContract : ActivityResultContract() { override fun createIntent(context: Context, input: Unit): Intent { return Intent(context, AppScanActivity::class.java)