Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 0 additions & 20 deletions app/src/generic/java/com/nextcloud/client/di/VariantModule.kt

This file was deleted.

23 changes: 0 additions & 23 deletions app/src/gplay/java/com/nextcloud/client/di/VariantModule.kt

This file was deleted.

23 changes: 0 additions & 23 deletions app/src/huawei/java/com/nextcloud/client/di/VariantModule.kt

This file was deleted.

5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@
<intent>
<action android:name="android.intent.action.GET_CONTENT" />
</intent>
<intent>
<!-- Since Android 11 (API level 30), this is required to detect the availability of FairScan to use
for external document scanning. See https://developer.android.com/training/package-visibility -->
<action android:name="org.fairscan.app.action.SCAN_TO_PDF" />
</intent>
</queries>

<application
Expand Down
45 changes: 45 additions & 0 deletions app/src/main/java/com/nextcloud/client/di/VariantModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Philipp Hasper <[email protected]>
* SPDX-FileCopyrightText: 2023 Álvaro Brey <[email protected]>
* 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<Unit, String?>
object : AppScanOptionalFeature() {
override fun getScanContract(): ActivityResultContract<Unit, String?> = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
(
Expand Down Expand Up @@ -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<Parcelable?>()

if (contentIntent.clipData != null && (contentIntent.clipData?.itemCount ?: 0) > 0) {
Expand All @@ -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<Uri, String?>? = 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,
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ class OCFileListBottomSheetDialog(
actions.scanDocUpload()
dismiss()
}
} else if (actions.isScanDocUploadFromAppAvailable) {
menuScanDocUpload.setOnClickListener {
actions.scanDocUploadFromApp()
dismiss()
}
} else {
menuScanDocUpload.visibility = View.GONE
}
Expand Down
Loading
Loading