diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef2466fd2d2b..96b4bb2c8557 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -240,6 +240,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 { @@ -422,6 +425,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")) @@ -438,6 +442,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 4f039da6bf63..31b00cb1d8b6 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 @@ -438,6 +438,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..735fc3cef497 --- /dev/null +++ b/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt @@ -0,0 +1,57 @@ +/* + * 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() + } + + /** + * In this variant, app scan should not be available + */ + @Test + fun testAppScanOptionalFeatureAvailability() { + val feature = component.appScanOptionalFeature() + + assertFalse(feature.isAvailable) + assertEquals(AppScanOptionalFeature.Stub, feature) + + // Verify that calling getScanContract on stub throws UnsupportedOperationException + try { + feature.getScanContract() + throw AssertionError("Expected UnsupportedOperationException") + } catch (e: UnsupportedOperationException) { + assertTrue(e.message?.contains("not available") == true) + } + } + + /** + * Dagger component for testing VariantModule in isolation + */ + @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..51bd43b65377 --- /dev/null +++ b/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt @@ -0,0 +1,54 @@ +/* + * 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() + } + + /** + * In this variant, app scan should be available + */ + @Test + fun testAppScanOptionalFeatureAvailability() { + val feature = component.appScanOptionalFeature() + + assertTrue(feature.isAvailable) + assertNotEquals(AppScanOptionalFeature.Stub, feature) + + // Verify that calling getScanContract returns without raising an exception + assertNotNull(feature.getScanContract()) + } + + /** + * Dagger component for testing VariantModule in isolation + */ + @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 ee6e7fd2bc80..18e7a7506570 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -107,6 +107,11 @@ + + + + + * 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 79925570bbac..4ea8c10dbf7a 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 @@ -44,6 +44,7 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView +import androidx.core.content.FileProvider import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -155,10 +156,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 @@ -970,10 +973,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 && ( @@ -1107,7 +1113,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) { @@ -1129,6 +1135,17 @@ class FileDisplayActivity : val currentDir = getCurrentDir() val remotePath = if (currentDir != null) currentDir.remotePath else OCFile.ROOT_PATH + var fileDisplayNameTransformer: androidx.core.util.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, @@ -1139,7 +1156,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() @@ -3139,6 +3157,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 cfe5f17f3f57..33e33b2b37a6 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 @@ -226,6 +226,8 @@ public class OCFileListFragment extends ExtendedListFragment implements private FloatingActionButton mFabMain; public static boolean isMultipleFileSelectedForCopyOrMove = false; + private static final Intent mFairScanIntent = new Intent("org.fairscan.app.action.SCAN_TO_PDF"); + @Inject DeviceInfo deviceInfo; protected enum MenuItemAddRemove { @@ -578,6 +580,22 @@ public void scanDocUpload() { } } + @Override + public void scanDocUploadFromApp() { + requireActivity().startActivityForResult( + mFairScanIntent, + FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME); + } + + @Override + public boolean isScanDocUploadFromAppAvailable() { + var context = getActivity(); + if (context == null) { + return false; + } + return mFairScanIntent.resolveActivity(context.getPackageManager()) != null; + } + @Override public void uploadFiles() { if (!(getActivity() instanceof FileActivity fileActivity)) { diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt index 9f08ad99bbce..6b4c63cd71d2 100644 --- a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt +++ b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt @@ -13,6 +13,7 @@ package com.owncloud.android.ui.helpers import android.content.ContentResolver import android.net.Uri import android.os.Parcelable +import androidx.core.util.Function import com.nextcloud.client.account.User import com.nextcloud.client.jobs.upload.FileUploadHelper import com.owncloud.android.R @@ -42,14 +43,16 @@ import com.owncloud.android.utils.UriUtils.getDisplayNameForUri "Detekt.SpreadOperator", "Detekt.TooGenericExceptionCaught" ) // legacy code -class UriUploader( +class UriUploader @JvmOverloads constructor( private val mActivity: FileActivity, private val mUrisToUpload: List, private val mUploadPath: String, private val user: User, private val mBehaviour: Int, private val mShowWaitingDialog: Boolean, - private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener? + private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener?, + /** If non-null, this function is called to determine the desired display name (i.e. filename) after upload**/ + private val mFileDisplayNameTransformer: Function? = null ) { enum class UriUploaderResultCode { @@ -113,7 +116,8 @@ class UriUploader( } private fun getRemotePathForUri(sourceUri: Uri): String { - val displayName = getDisplayNameForUri(sourceUri, mActivity) + val displayName = mFileDisplayNameTransformer?.apply(sourceUri) + ?: getDisplayNameForUri(sourceUri, mActivity) require(displayName != null) { "Display name cannot be null" } return mUploadPath + displayName } 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)