From 00476687b97bb4fd072a2bd40830820fcd7f427c Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 20 Sep 2026 09:44:56 +0000 Subject: [PATCH 01/12] refactor(scan): remove PDF-from-images background job Dead code once nothing calls startPdfGenerateAndUploadWork(): this job only existed to combine DocumentScanActivity's scanned pages into one PDF for upload, and that flow is being replaced by an embedded FairScan, which produces the finished PDF itself. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Philipp Hasper --- .../documentscan/GeneratePDFUseCaseTest.kt | 71 ---------- .../client/documentscan/GeneratePDFUseCase.kt | 62 --------- .../documentscan/GeneratePdfFromImagesWork.kt | 123 ------------------ .../client/jobs/BackgroundJobFactory.kt | 15 --- .../client/jobs/BackgroundJobManager.kt | 2 - .../client/jobs/BackgroundJobManagerImpl.kt | 20 --- app/src/main/res/values/strings.xml | 2 - .../client/jobs/BackgroundJobFactoryTest.kt | 5 - 8 files changed, 300 deletions(-) delete mode 100644 app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt delete mode 100644 app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt delete mode 100644 app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt b/app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt deleted file mode 100644 index 36402fb52d5f..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt +++ /dev/null @@ -1,71 +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.documentscan - -import android.graphics.pdf.PdfRenderer -import android.os.ParcelFileDescriptor -import com.nextcloud.client.logger.Logger -import com.owncloud.android.AbstractIT -import io.mockk.MockKAnnotations -import io.mockk.impl.annotations.MockK -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.File - -internal class GeneratePDFUseCaseTest : AbstractIT() { - - @MockK - private lateinit var logger: Logger - - private lateinit var sut: GeneratePDFUseCase - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - sut = GeneratePDFUseCase(logger) - } - - @Test - fun invalidArguments_shouldReturnFalse() { - var result = sut.execute(emptyList(), "/test/foo.pdf") - assertFalse("Usecase does not indicate failure with invalid arguments", result) - result = sut.execute(listOf("/test.jpg"), "") - assertFalse("Usecase does not indicate failure with invalid arguments", result) - } - - @Test - fun generatePdf_checkPages() { - // can't think of how to test the _content_ of the pages - val images = listOf( - getFile("image.jpg"), - getFile("christine.jpg") - ).map { it.path } - - val output = "/sdcard/test.pdf" - - val result = sut.execute(images, output) - - assertTrue("Usecase does not indicate success", result) - - val outputFile = File(output) - - assertTrue("Output file does not exist", outputFile.exists()) - - ParcelFileDescriptor.open(outputFile, ParcelFileDescriptor.MODE_READ_ONLY).use { - PdfRenderer(it).use { renderer -> - val pageCount = renderer.pageCount - assertTrue("Page count is not correct", pageCount == 2) - } - } - - // clean up - outputFile.delete() - } -} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt b/app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt deleted file mode 100644 index 87bd71b87e17..000000000000 --- a/app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt +++ /dev/null @@ -1,62 +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.documentscan - -import android.graphics.BitmapFactory -import android.graphics.pdf.PdfDocument -import com.nextcloud.client.logger.Logger -import java.io.FileOutputStream -import java.io.IOException -import javax.inject.Inject - -/** - * This class takes a list of images and generates a PDF file. - */ -class GeneratePDFUseCase @Inject constructor(private val logger: Logger) { - /** - * @param imagePaths list of image paths - * @return `true` if the PDF was generated successfully, `false` otherwise - */ - fun execute(imagePaths: List, filePath: String): Boolean = if (imagePaths.isEmpty() || filePath.isBlank()) { - logger.w(TAG, "Invalid parameters: imagePaths: $imagePaths, filePath: $filePath") - false - } else { - val document = PdfDocument() - fillDocumentPages(document, imagePaths) - writePdfToFile(filePath, document) - } - - /** - * @return `true` if the PDF was generated successfully, `false` otherwise - */ - private fun writePdfToFile(filePath: String, document: PdfDocument): Boolean = try { - val fileOutputStream = FileOutputStream(filePath) - document.writeTo(fileOutputStream) - fileOutputStream.close() - document.close() - true - } catch (ex: IOException) { - logger.e(TAG, "Error generating PDF", ex) - false - } - - private fun fillDocumentPages(document: PdfDocument, imagePaths: List) { - imagePaths.forEach { path -> - val bitmap = BitmapFactory.decodeFile(path) - val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create() - val page = document.startPage(pageInfo) - page.canvas.drawBitmap(bitmap, 0f, 0f, null) - document.finishPage(page) - bitmap.recycle() - } - } - - companion object { - private const val TAG = "GeneratePDFUseCase" - } -} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt b/app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt deleted file mode 100644 index f400bd5392b5..000000000000 --- a/app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Tobias Kaminsky - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.documentscan - -import android.app.NotificationManager -import android.content.Context -import android.graphics.BitmapFactory -import androidx.annotation.StringRes -import androidx.core.app.NotificationCompat -import androidx.work.Worker -import androidx.work.WorkerParameters -import com.nextcloud.client.account.AnonymousUser -import com.nextcloud.client.account.User -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.jobs.upload.FileUploadHelper -import com.nextcloud.client.jobs.upload.FileUploadWorker -import com.nextcloud.client.logger.Logger -import com.nextcloud.model.OCUploadLocalPathData -import com.owncloud.android.R -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.services.NameCollisionPolicy -import com.owncloud.android.operations.UploadFileOperation -import com.owncloud.android.ui.notifications.NotificationUtils -import com.owncloud.android.utils.theme.ViewThemeUtils -import java.io.File -import java.security.SecureRandom - -@Suppress("Detekt.LongParameterList") // constructed only from factory method and tests -class GeneratePdfFromImagesWork( - private val appContext: Context, - private val generatePdfUseCase: GeneratePDFUseCase, - private val viewThemeUtils: ViewThemeUtils, - private val notificationManager: NotificationManager, - private val userAccountManager: UserAccountManager, - private val logger: Logger, - params: WorkerParameters -) : Worker(appContext, params) { - - override fun doWork(): Result { - val inputPaths = inputData.getStringArray(INPUT_IMAGE_FILE_PATHS)?.toList() - val outputFilePath = inputData.getString(INPUT_OUTPUT_FILE_PATH) - val uploadFolder = inputData.getString(INPUT_UPLOAD_FOLDER) - val accountName = inputData.getString(INPUT_UPLOAD_ACCOUNT) - - @Suppress("Detekt.ComplexCondition") // not that complex - require(!inputPaths.isNullOrEmpty() && outputFilePath != null && uploadFolder != null && accountName != null) { - "PDF generation work started with missing parameters:" + - " inputPaths: $inputPaths, outputFilePath: $outputFilePath," + - " uploadFolder: $uploadFolder, accountName: $accountName" - } - - val user = userAccountManager.getUser(accountName) - require(user.isPresent && user.get() !is AnonymousUser) { "Invalid or not found user" } - - logger.d( - TAG, - "PDF generation work started with parameters: inputPaths=$inputPaths," + - "outputFilePath=$outputFilePath, uploadFolder=$uploadFolder, accountName=$accountName" - ) - - val notificationId = showNotification(R.string.document_scan_pdf_generation_in_progress) - val result = generatePdfUseCase.execute(inputPaths, outputFilePath) - notificationManager.cancel(notificationId) - if (result) { - uploadFile(user.get(), uploadFolder, outputFilePath) - cleanupImages(inputPaths) - } else { - logger.w(TAG, "PDF generation failed") - showNotification(R.string.document_scan_pdf_generation_failed) - return Result.failure() - } - - logger.d(TAG, "PDF generation work finished") - return Result.success() - } - - private fun cleanupImages(inputPaths: List) { - inputPaths.forEach { - val deleted = File(it).delete() - logger.d(TAG, "Deleted $it: success = $deleted") - } - } - - private fun showNotification(@StringRes messageRes: Int): Int { - val notificationId = SecureRandom().nextInt() - val message = appContext.getString(messageRes) - - val notificationBuilder = NotificationCompat.Builder( - appContext, - NotificationUtils.NOTIFICATION_CHANNEL_GENERAL - ) - .setSmallIcon(R.drawable.notification_icon) - .setLargeIcon(BitmapFactory.decodeResource(appContext.resources, R.drawable.notification_icon)) - .setContentText(message) - .setAutoCancel(true) - - viewThemeUtils.androidx.themeNotificationCompatBuilder(appContext, notificationBuilder) - - notificationManager.notify(notificationId, notificationBuilder.build()) - - return notificationId - } - - private fun uploadFile(user: User, uploadFolder: String, pdfPath: String) { - val uploadPath = uploadFolder + OCFile.PATH_SEPARATOR + File(pdfPath).name - - val data = OCUploadLocalPathData.forDocument(user, arrayOf(pdfPath), arrayOf(uploadPath)) - FileUploadHelper().uploadNewFiles(data) - } - - companion object { - const val INPUT_IMAGE_FILE_PATHS = "input_image_file_paths" - const val INPUT_OUTPUT_FILE_PATH = "input_output_file_path" - const val INPUT_UPLOAD_FOLDER = "input_upload_folder" - const val INPUT_UPLOAD_ACCOUNT = "input_upload_account" - private const val TAG = "GeneratePdfFromImagesWo" - } -} diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 166b9fc0334d..0ee6d52b254b 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -20,8 +20,6 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.core.Clock import com.nextcloud.client.database.NextcloudDatabase import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.documentscan.GeneratePDFUseCase -import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork import com.nextcloud.client.integrations.deck.DeckApi import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper import com.nextcloud.client.jobs.autoUpload.AutoUploadLocalDeletionWorker @@ -69,7 +67,6 @@ class BackgroundJobFactory @Inject constructor( private val deckApi: DeckApi, private val viewThemeUtils: Provider, private val localBroadcastManager: Provider, - private val generatePdfUseCase: GeneratePDFUseCase, private val syncedFolderProvider: SyncedFolderProvider, private val database: NextcloudDatabase, private val uploadFileOperationFactory: UploadFileOperationFactory @@ -106,7 +103,6 @@ class BackgroundJobFactory @Inject constructor( FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters) AlbumFileUploadWorker::class -> createAlbumsFilesUploadWorker(context, workerParameters) FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters) - GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters) HealthStatusWork::class -> createHealthStatusWork(context, workerParameters) TestJob::class -> createTestJob(context, workerParameters) OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters) @@ -292,17 +288,6 @@ class BackgroundJobFactory @Inject constructor( params ) - private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork = - GeneratePdfFromImagesWork( - appContext = context, - generatePdfUseCase = generatePdfUseCase, - viewThemeUtils = viewThemeUtils.get(), - notificationManager = notificationManager, - userAccountManager = accountManager, - logger = logger, - params = params - ) - private fun createHealthStatusWork(context: Context, params: WorkerParameters): HealthStatusWork = HealthStatusWork( context, params, diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index da7d997f592c..3ba5d2631429 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -163,8 +163,6 @@ interface BackgroundJobManager { conflictUploadId: Long? ) - fun startPdfGenerateAndUploadWork(user: User, uploadFolder: String, imagePaths: List, pdfPath: String) - fun scheduleTestJob() fun startImmediateTestJob() fun cancelTestJob() diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 3e110c414e42..4073f0ad8e84 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -27,7 +27,6 @@ import androidx.work.workDataOf import com.nextcloud.client.account.User import com.nextcloud.client.core.Clock import com.nextcloud.client.di.Injectable -import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork import com.nextcloud.client.jobs.autoUpload.AutoUploadLocalDeletionWorker import com.nextcloud.client.jobs.autoUpload.AutoUploadRescanWorker import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker @@ -98,7 +97,6 @@ internal class BackgroundJobManagerImpl( const val JOB_FILES_UPLOAD = "files_upload" const val JOB_FOLDER_DOWNLOAD = "folder_download" const val JOB_FILES_DOWNLOAD = "files_download" - const val JOB_PDF_GENERATION = "pdf_generation" const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup" const val JOB_IMMEDIATE_FILES_EXPORT = "immediate_files_export" const val JOB_OFFLINE_OPERATIONS = "offline_operations" @@ -827,24 +825,6 @@ internal class BackgroundJobManagerImpl( workManager.cancelAllWorkByTag(formatFileDownloadTag(accountName, fileId)) } - override fun startPdfGenerateAndUploadWork( - user: User, - uploadFolder: String, - imagePaths: List, - pdfPath: String - ) { - val data = workDataOf( - GeneratePdfFromImagesWork.INPUT_IMAGE_FILE_PATHS to imagePaths.toTypedArray(), - GeneratePdfFromImagesWork.INPUT_OUTPUT_FILE_PATH to pdfPath, - GeneratePdfFromImagesWork.INPUT_UPLOAD_ACCOUNT to user.accountName, - GeneratePdfFromImagesWork.INPUT_UPLOAD_FOLDER to uploadFolder - ) - val request = oneTimeRequestBuilder(GeneratePdfFromImagesWork::class, JOB_PDF_GENERATION) - .setInputData(data) - .build() - workManager.enqueue(request) - } - override fun scheduleTestJob() { val request = periodicRequestBuilder(TestJob::class, JOB_TEST) .setInitialDelay(DEFAULT_IMMEDIATE_JOB_DELAY_SEC, TimeUnit.SECONDS) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6aa0898face..ee332a5a7b37 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1455,9 +1455,7 @@ Not possible without internet connection Scan page Done - Generating PDF… Error starting document scan - PDF generation failed Choose export type PDF file Multiple images diff --git a/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt b/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt index e636c304bb82..2f653570bdc2 100644 --- a/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt @@ -20,7 +20,6 @@ import com.nextcloud.client.database.dao.FileDao import com.nextcloud.client.database.dao.FileSystemDao import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.documentscan.GeneratePDFUseCase import com.nextcloud.client.integrations.deck.DeckApi import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService @@ -100,9 +99,6 @@ class BackgroundJobFactoryTest { @Mock private lateinit var localBroadcastManager: LocalBroadcastManager - @Mock - private lateinit var generatePDFUseCase: GeneratePDFUseCase - @Mock private lateinit var syncedFolderProvider: SyncedFolderProvider @@ -144,7 +140,6 @@ class BackgroundJobFactoryTest { deckApi, { viewThemeUtils }, { localBroadcastManager }, - generatePDFUseCase, syncedFolderProvider, db, uploadFileOperationFactory From 4007bcee3c7c40eced9abce8371ceb8a020216e9 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 20 Sep 2026 09:48:01 +0000 Subject: [PATCH 02/12] refactor(scan): remove built-in multi-page scan screen DocumentScanActivity's own page-collection UI (backed by the Hazzatur Document-Scanning-Android-SDK via AppScanOptionalFeature) is being replaced by an embedded FairScan, which handles its own multi-page capture and PDF export. Removing it here together with its only caller keeps the tree buildable; the cross-app "launch FairScan if installed separately" fallback in OCFileListBottomSheetDialog is now the sole scan path until the embedded replacement lands. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Philipp Hasper --- .../android/ui/dialog/DialogFragmentIT.kt | 11 +- app/src/main/AndroidManifest.xml | 4 - .../nextcloud/client/di/ComponentsModule.kt | 4 - .../nextcloud/client/di/ViewModelModule.kt | 6 - .../documentscan/DocumentPageListAdapter.kt | 44 ---- .../documentscan/DocumentScanActivity.kt | 207 ------------------ .../documentscan/DocumentScanViewModel.kt | 189 ---------------- .../fragment/OCFileListBottomSheetActions.kt | 1 - .../fragment/OCFileListBottomSheetDialog.kt | 11 +- .../ui/fragment/OCFileListFragment.java | 23 +- .../res/layout/activity_document_scan.xml | 41 ---- .../res/layout/dialog_scan_export_type.xml | 38 ---- .../main/res/layout/document_page_item.xml | 14 -- .../main/res/menu/activity_document_scan.xml | 17 -- app/src/main/res/values/strings.xml | 6 - 15 files changed, 4 insertions(+), 612 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/documentscan/DocumentPageListAdapter.kt delete mode 100644 app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt delete mode 100644 app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt delete mode 100644 app/src/main/res/layout/activity_document_scan.xml delete mode 100644 app/src/main/res/layout/dialog_scan_export_type.xml delete mode 100644 app/src/main/res/layout/document_page_item.xml delete mode 100644 app/src/main/res/menu/activity_document_scan.xml 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 70e39466ee99..8767648d704f 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 @@ -19,7 +19,6 @@ import android.os.Looper import android.view.ViewGroup import android.webkit.SslErrorHandler import android.widget.TextView -import androidx.activity.result.contract.ActivityResultContract import androidx.fragment.app.DialogFragment import androidx.test.core.app.ActivityScenario import androidx.test.core.app.launchActivity @@ -37,7 +36,6 @@ import com.nextcloud.android.lib.resources.profile.HoverCard import com.nextcloud.client.account.RegisteredUser import com.nextcloud.client.account.Server import com.nextcloud.client.device.DeviceInfo -import com.nextcloud.client.documentscan.AppScanOptionalFeature import com.nextcloud.ui.dialog.account.ChooseAccountDialogFragment import com.nextcloud.ui.dialog.account.ChooseAccountDialogFragment.Companion.newInstance import com.nextcloud.ui.dialog.account.onlineStatus.SetOnlineStatusBottomSheet @@ -433,7 +431,6 @@ class DialogFragmentIT : AbstractIT() { override fun newSpreadsheet() = Unit override fun newPresentation() = Unit override fun directCameraUpload() = Unit - override fun scanDocUpload() = Unit override fun scanDocUploadFromApp() = Unit override val isScanDocUploadFromAppAvailable: Boolean get() = false @@ -503,11 +500,6 @@ class DialogFragmentIT : AbstractIT() { } CapabilityUtils.updateCapability(capability) - val appScanOptionalFeature: AppScanOptionalFeature = object : AppScanOptionalFeature() { - override fun getScanContract(): ActivityResultContract = - throw UnsupportedOperationException("Document scan is not available") - } - val viewThemeUtils = ViewThemeUtils( materialSchemesForCurrentUser, ColorUtil(targetContext) @@ -523,8 +515,7 @@ class DialogFragmentIT : AbstractIT() { ocFile, fda.themeUtils, viewThemeUtils, - editorUtils, - appScanOptionalFeature + editorUtils ) sut.show() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f20c2687a55f..4ecdcd11bd8f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -666,10 +666,6 @@ android:name=".ui.preview.pdf.PreviewBitmapActivity" android:exported="false" android:theme="@style/Theme.ownCloud.OverlayGrey" /> - - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.documentscan - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import coil.load -import com.owncloud.android.databinding.DocumentPageItemBinding - -class DocumentPageListAdapter : - ListAdapter(DiffItemCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DocumentPageViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = DocumentPageItemBinding.inflate(inflater, parent, false) - return DocumentPageViewHolder(binding) - } - - override fun onBindViewHolder(holder: DocumentPageViewHolder, position: Int) { - holder.bind(currentList[position]) - } - - override fun getItemCount(): Int = currentList.size - - class DocumentPageViewHolder(val binding: DocumentPageItemBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(imagePath: String) { - binding.root.load(imagePath) - } - } - - private class DiffItemCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: String, newItem: String) = oldItem == newItem - - override fun areContentsTheSame(oldItem: String, newItem: String) = oldItem == newItem - } -} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt deleted file mode 100644 index 7424948cae90..000000000000 --- a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.documentscan - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.activity.result.ActivityResultLauncher -import androidx.appcompat.app.AlertDialog -import androidx.core.view.MenuProvider -import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.GridLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.di.ViewModelFactory -import com.nextcloud.client.logger.Logger -import com.owncloud.android.R -import com.owncloud.android.databinding.ActivityDocumentScanBinding -import com.owncloud.android.databinding.DialogScanExportTypeBinding -import com.owncloud.android.ui.activity.ToolbarActivity -import com.owncloud.android.utils.theme.ViewThemeUtils -import javax.inject.Inject - -class DocumentScanActivity : - ToolbarActivity(), - Injectable { - - @Inject - lateinit var vmFactory: ViewModelFactory - - @Inject - lateinit var logger: Logger - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - @Inject - lateinit var appScanOptionalFeature: AppScanOptionalFeature - - lateinit var binding: ActivityDocumentScanBinding - - lateinit var viewModel: DocumentScanViewModel - - private var scanPage: ActivityResultLauncher? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - scanPage = registerForActivityResult(appScanOptionalFeature.getScanContract()) { result -> - viewModel.onScanPageResult(result) - } - - val folder = intent.extras?.getString(EXTRA_FOLDER) - require(folder != null) { "Folder must be provided for upload" } - - viewModel = ViewModelProvider(this, vmFactory)[DocumentScanViewModel::class.java] - viewModel.setUploadFolder(folder) - - setupViews() - - observeState() - } - - private fun setupViews() { - binding = ActivityDocumentScanBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupToolbar() - supportActionBar?.let { - it.setDisplayHomeAsUpEnabled(true) - it.setDisplayShowHomeEnabled(true) - viewThemeUtils.files.themeActionBar(this, it) - } - - viewThemeUtils.material.themeFAB(binding.fab) - binding.fab.setOnClickListener { - viewModel.onAddPageClicked() - } - - binding.pagesRecycler.layoutManager = GridLayoutManager(this, PAGE_COLUMNS) - - setupMenu() - } - - private fun setupMenu() { - addMenuProvider( - object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.activity_document_scan, menu) - menu.findItem(R.id.action_save)?.let { - viewThemeUtils.platform.colorToolbarMenuIcon(this@DocumentScanActivity, it) - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.action_save -> { - viewModel.onClickDone() - true - } - - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - - else -> false - } - } - ) - } - - private fun observeState() { - viewModel.uiState.observe(this, ::handleState) - } - - private fun handleState(state: DocumentScanViewModel.UIState) { - logger.d(TAG, "handleState: called with $state") - when (state) { - is DocumentScanViewModel.UIState.BaseState -> when (state) { - is DocumentScanViewModel.UIState.NormalState -> { - updateButtonsEnabled(true) - val pageList = state.pageList - updateRecycler(pageList) - if (state.shouldRequestScan) { - startPageScan() - } - } - - is DocumentScanViewModel.UIState.RequestExportState -> { - updateButtonsEnabled(false) - if (state.shouldRequestExportType) { - showExportDialog() - viewModel.onRequestTypeHandled() - } - } - } - - DocumentScanViewModel.UIState.DoneState, DocumentScanViewModel.UIState.CanceledState -> { - finish() - } - } - } - - private fun showExportDialog() { - val dialogBinding = DialogScanExportTypeBinding.inflate(layoutInflater) - - val dialog = MaterialAlertDialogBuilder(this) - .setTitle(R.string.document_scan_export_dialog_title) - .setCancelable(true) - .setView(dialogBinding.root) - .setNegativeButton(R.string.common_cancel) { _, _ -> - viewModel.onExportCanceled() - } - .setOnCancelListener { viewModel.onExportCanceled() } - .also { - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this@DocumentScanActivity, it) - } - .create() - - viewThemeUtils.platform.colorTextButtons(dialogBinding.btnPdf, dialogBinding.btnImages) - - dialogBinding.btnPdf.setOnClickListener { - viewModel.onExportTypeSelected(DocumentScanViewModel.ExportType.PDF) - dialog.dismiss() - } - dialogBinding.btnImages.setOnClickListener { - viewModel.onExportTypeSelected(DocumentScanViewModel.ExportType.IMAGES) - dialog.dismiss() - } - - dialog.setOnShowListener { - val alertDialog = it as AlertDialog - viewThemeUtils.platform.colorTextButtons(alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE)) - } - - dialog.show() - } - - private fun updateRecycler(pageList: List) { - if (binding.pagesRecycler.adapter == null) { - binding.pagesRecycler.adapter = DocumentPageListAdapter() - } - (binding.pagesRecycler.adapter as? DocumentPageListAdapter)?.submitList(pageList) - } - - private fun updateButtonsEnabled(enabled: Boolean) { - binding.fab.isEnabled = enabled - } - - private fun startPageScan() { - logger.d(TAG, "startPageScan() called") - viewModel.onScanRequestHandled() - scanPage!!.launch(Unit) - } - - companion object { - private const val TAG = "DocumentScanActivity" - private const val PAGE_COLUMNS = 2 - const val EXTRA_FOLDER = "extra_folder" - } -} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt deleted file mode 100644 index 72655d3142d1..000000000000 --- a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.documentscan - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.nextcloud.client.account.CurrentAccountProvider -import com.nextcloud.client.di.IoDispatcher -import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.jobs.upload.FileUploadHelper -import com.nextcloud.client.jobs.upload.FileUploadWorker -import com.nextcloud.client.logger.Logger -import com.nextcloud.model.OCUploadLocalPathData -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.services.NameCollisionPolicy -import com.owncloud.android.operations.UploadFileOperation -import com.owncloud.android.ui.helpers.FileOperationsHelper -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.File -import javax.inject.Inject - -@Suppress("Detekt.LongParameterList") // satisfied by DI -class DocumentScanViewModel @Inject constructor( - @IoDispatcher private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - app: Application, - private val logger: Logger, - private val backgroundJobManager: BackgroundJobManager, - private val currentAccountProvider: CurrentAccountProvider -) : AndroidViewModel(app) { - init { - logger.d(TAG, "DocumentScanViewModel created") - } - - sealed interface UIState { - sealed class BaseState(val pageList: List) : UIState { - val isEmpty: Boolean - get() = pageList.isEmpty() - } - - class NormalState(pageList: List = emptyList(), val shouldRequestScan: Boolean = false) : - BaseState(pageList) - - class RequestExportState(pageList: List = emptyList(), val shouldRequestExportType: Boolean = true) : - BaseState(pageList) - - object DoneState : UIState - object CanceledState : UIState - } - - private var uploadFolder: String? = null - private val initialState = UIState.NormalState(shouldRequestScan = true) - private val _uiState = MutableLiveData(initialState) - val uiState: LiveData - get() = _uiState - - /** - * @param result should be the path to the scanned page on the disk - */ - fun onScanPageResult(result: String?) { - logger.d(TAG, "onScanPageResult() called with: result = $result") - - val state = _uiState.value - require(state is UIState.NormalState) - - viewModelScope.launch(ioDispatcher) { - if (result != null) { - val newPath = renameCapturedImage(result) - val pageList = state.pageList.toMutableList() - pageList.add(newPath) - _uiState.postValue(UIState.NormalState(pageList)) - } else { - // result == null means cancellation or error - if (state.isEmpty) { - // close only if no pages have been added yet - _uiState.postValue(UIState.CanceledState) - } - } - } - } - - // TODO extract to usecase - private fun renameCapturedImage(originalPath: String): String { - val file = File(originalPath) - val renamedFile = - File( - getApplication().cacheDir.path + - File.separator + FileOperationsHelper.getCapturedImageName() - ) - file.renameTo(renamedFile) - return renamedFile.absolutePath - } - - fun onScanRequestHandled() { - val state = uiState.value - require(state is UIState.NormalState) - - _uiState.postValue(UIState.NormalState(state.pageList, shouldRequestScan = false)) - } - - fun onAddPageClicked() { - val state = uiState.value - require(state is UIState.NormalState) - if (!state.shouldRequestScan) { - _uiState.postValue(UIState.NormalState(state.pageList, shouldRequestScan = true)) - } - } - - fun onClickDone() { - val state = _uiState.value - if (state is UIState.BaseState && !state.isEmpty) { - _uiState.postValue(UIState.RequestExportState(state.pageList)) - } - } - - fun setUploadFolder(folder: String) { - this.uploadFolder = folder - } - - fun onRequestTypeHandled() { - val state = _uiState.value - require(state is UIState.RequestExportState) - _uiState.postValue(UIState.RequestExportState(state.pageList, false)) - } - - fun onExportTypeSelected(exportType: ExportType) { - val state = _uiState.value - require(state is UIState.RequestExportState) - when (exportType) { - ExportType.PDF -> { - exportToPdf(state.pageList) - } - - ExportType.IMAGES -> { - exportToImages(state.pageList) - } - } - _uiState.postValue(UIState.DoneState) - } - - private fun exportToPdf(pageList: List) { - val genPath = - getApplication().cacheDir.path + File.separator + FileOperationsHelper.getTimestampedFileName( - ".pdf" - ) - backgroundJobManager.startPdfGenerateAndUploadWork( - currentAccountProvider.user, - uploadFolder!!, - pageList, - genPath - ) - // after job is started, finish the application. - _uiState.postValue(UIState.DoneState) - } - - private fun exportToImages(pageList: List) { - val uploadPaths = pageList.map { - uploadFolder + OCFile.PATH_SEPARATOR + File(it).name - }.toTypedArray() - - val data = OCUploadLocalPathData.forDocument(currentAccountProvider.user, pageList.toTypedArray(), uploadPaths) - FileUploadHelper.instance().uploadNewFiles(data) - } - - fun onExportCanceled() { - val state = _uiState.value - if (state is UIState.BaseState) { - _uiState.postValue(UIState.NormalState(state.pageList)) - } - } - - private companion object { - private const val TAG = "DocumentScanViewModel" - } - - enum class ExportType { - PDF, - IMAGES - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt index 5f8a7df59994..e0d9a66b819d 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt @@ -33,7 +33,6 @@ interface OCFileListBottomSheetActions { fun newPresentation() fun directCameraUpload() - fun scanDocUpload() /** * Offers scanning a document in a supported external app and then upload to the current folder. 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 9466a9fd71a2..d2637f5a6cea 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 @@ -28,7 +28,6 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.User import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.di.Injectable -import com.nextcloud.client.documentscan.AppScanOptionalFeature import com.nextcloud.utils.BuildHelper.isFlavourGPlay import com.nextcloud.utils.EditorUtils import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended @@ -59,8 +58,7 @@ class OCFileListBottomSheetDialog( private val file: OCFile, private val themeUtils: ThemeUtils, private val viewThemeUtils: ViewThemeUtils, - private val editorUtils: EditorUtils, - private val appScanOptionalFeature: AppScanOptionalFeature + private val editorUtils: EditorUtils ) : BottomSheetDialog(fileActivity), Injectable { @@ -397,12 +395,7 @@ class OCFileListBottomSheetDialog( dismiss() } - if (appScanOptionalFeature.isAvailable) { - menuScanDocUpload.setOnClickListener { - actions.scanDocUpload() - dismiss() - } - } else if (actions.isScanDocUploadFromAppAvailable) { + if (actions.isScanDocUploadFromAppAvailable) { menuScanDocUpload.setOnClickListener { actions.scanDocUploadFromApp() dismiss() 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 6e42ac67cd7c..f9ee5e42cc61 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 @@ -44,8 +44,6 @@ import com.nextcloud.client.account.User; import com.nextcloud.client.device.DeviceInfo; import com.nextcloud.client.di.Injectable; -import com.nextcloud.client.documentscan.AppScanOptionalFeature; -import com.nextcloud.client.documentscan.DocumentScanActivity; import com.nextcloud.client.jobs.BackgroundJobManager; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.systembars.SystemBarBackgroundCallbacks; @@ -203,7 +201,6 @@ public class OCFileListFragment extends ExtendedListFragment implements @Inject EditorUtils editorUtils; @Inject SyncedFolderProvider syncedFolderProvider; @Inject FileActionHandler fileActionHandler; - @Inject AppScanOptionalFeature appScanOptionalFeature; @Inject ThumbnailGenerator thumbnailGenerator; @Inject AvatarGenerator avatarGenerator; @@ -568,8 +565,7 @@ public void registerFabListener() { currentDir, themeUtils, viewThemeUtils, - editorUtils, - appScanOptionalFeature); + editorUtils); dialog.getBehavior().setState(BottomSheetBehavior.STATE_EXPANDED); dialog.getBehavior().setSkipCollapsed(true); @@ -640,23 +636,6 @@ private void showDirectCameraUploadAlertDialog(FileDisplayActivity fileDisplayAc builder.show(); } - @Override - public void scanDocUpload() { - FileDisplayActivity fileDisplayActivity = (FileDisplayActivity) getActivity(); - - final OCFile currentFile = getCurrentFile(); - if (fileDisplayActivity != null && currentFile != null && currentFile.isFolder()) { - - Intent intent = new Intent(requireContext(), DocumentScanActivity.class); - intent.putExtra(DocumentScanActivity.EXTRA_FOLDER, currentFile.getRemotePath()); - startActivity(intent); - } else { - Log.w(TAG, "scanDocUpload: Failed to start doc scanning, fileDisplayActivity=" + fileDisplayActivity + - ", currentFile=" + currentFile); - SnackbarUtil.show(this, R.string.error_starting_doc_scan); - } - } - @Override public void scanDocUploadFromApp() { requireActivity().startActivityForResult( diff --git a/app/src/main/res/layout/activity_document_scan.xml b/app/src/main/res/layout/activity_document_scan.xml deleted file mode 100644 index 11e8d9e74acf..000000000000 --- a/app/src/main/res/layout/activity_document_scan.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_scan_export_type.xml b/app/src/main/res/layout/dialog_scan_export_type.xml deleted file mode 100644 index 592a50e1a884..000000000000 --- a/app/src/main/res/layout/dialog_scan_export_type.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/document_page_item.xml b/app/src/main/res/layout/document_page_item.xml deleted file mode 100644 index 4ce3795b4c12..000000000000 --- a/app/src/main/res/layout/document_page_item.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/app/src/main/res/menu/activity_document_scan.xml b/app/src/main/res/menu/activity_document_scan.xml deleted file mode 100644 index 7297fb0d67af..000000000000 --- a/app/src/main/res/menu/activity_document_scan.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee332a5a7b37..7a85ad23eee8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1453,12 +1453,6 @@ Add end-to-end encryption to this client Add new secure file drop Not possible without internet connection - Scan page - Done - Error starting document scan - Choose export type - PDF file - Multiple images Cannot create local file Invalid filename for local file Team folders From 53d7dd0c808571c483b1187762aa927799cc04af Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 20 Sep 2026 09:49:12 +0000 Subject: [PATCH 03/12] refactor(scan): remove :appscan module and its reflection wiring Nothing references AppScanOptionalFeature/VariantModule's reflection lookup of com.nextcloud.appscan.ScanPageContract anymore, so the Hazzatur Document-Scanning-Android-SDK dependency and the :appscan module it lived in go too. The JitPack repo in settings.gradle.kts stays - other dependencies still resolve through it. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Philipp Hasper --- app/build.gradle.kts | 14 ---- .../nextcloud/client/di/VariantModuleTest.kt | 50 -------------- .../nextcloud/client/di/VariantModuleTest.kt | 47 ------------- .../com/nextcloud/client/di/AppComponent.kt | 1 - .../com/nextcloud/client/di/VariantModule.kt | 45 ------------- .../documentscan/AppScanOptionalFeature.kt | 29 -------- appscan/build.gradle.kts | 48 ------------- appscan/src/main/AndroidManifest.xml | 20 ------ .../com/nextcloud/appscan/AppScanActivity.kt | 67 ------------------- .../com/nextcloud/appscan/ScanPageContract.kt | 27 -------- gradle/libs.versions.toml | 2 - settings.gradle.kts | 2 +- 12 files changed, 1 insertion(+), 351 deletions(-) delete mode 100644 app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt delete mode 100644 app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt delete mode 100644 app/src/main/java/com/nextcloud/client/di/VariantModule.kt delete mode 100644 app/src/main/java/com/nextcloud/client/documentscan/AppScanOptionalFeature.kt delete mode 100644 appscan/build.gradle.kts delete mode 100644 appscan/src/main/AndroidManifest.xml delete mode 100644 appscan/src/main/java/com/nextcloud/appscan/AppScanActivity.kt delete mode 100644 appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a89268efe4f3..8f2f8d64e0b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -346,13 +346,6 @@ tasks.named("check").configure { val kspConfiguration = "ksp" val kspAndroidTestConfiguration = "kspAndroidTest" val gplayImplementationConfiguration = "gplayImplementation" -val huaweiImplementationConfiguration = "huaweiImplementation" -val qaImplementationConfiguration = "qaImplementation" -val appScanConfigurations = listOf( - gplayImplementationConfiguration, - huaweiImplementationConfiguration, - qaImplementationConfiguration -) dependencies { // region Nextcloud library @@ -448,13 +441,6 @@ dependencies { implementation(libs.emoji.google) // endregion - // region AppScan, document scanner not available on FDroid (generic) due to OpenCV binaries - // To enable the feature for another variant, add its "Implementation" here. - appScanConfigurations.forEach { configuration -> - add(configuration, project(":appscan")) - } - // endregion - // region SpotBugs spotbugsPlugins(libs.findsecbugs.plugin) spotbugsPlugins(libs.fb.contrib) diff --git a/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt b/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt deleted file mode 100644 index ed7d07d4db34..000000000000 --- a/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 deleted file mode 100644 index 3ac80ebe9d6c..000000000000 --- a/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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/main/java/com/nextcloud/client/di/AppComponent.kt b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt index 75efe13470f8..e9e680634f52 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt @@ -47,7 +47,6 @@ import javax.inject.Singleton ThemeModule::class, DatabaseModule::class, DispatcherModule::class, - VariantModule::class, PlayerModule::class ] ) diff --git a/app/src/main/java/com/nextcloud/client/di/VariantModule.kt b/app/src/main/java/com/nextcloud/client/di/VariantModule.kt deleted file mode 100644 index 78899be6aec7..000000000000 --- a/app/src/main/java/com/nextcloud/client/di/VariantModule.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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/nextcloud/client/documentscan/AppScanOptionalFeature.kt b/app/src/main/java/com/nextcloud/client/documentscan/AppScanOptionalFeature.kt deleted file mode 100644 index d6996e17fcd5..000000000000 --- a/app/src/main/java/com/nextcloud/client/documentscan/AppScanOptionalFeature.kt +++ /dev/null @@ -1,29 +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.documentscan - -import androidx.activity.result.contract.ActivityResultContract - -abstract class AppScanOptionalFeature { - /** - * Check [isAvailable] before calling this method. - */ - abstract fun getScanContract(): ActivityResultContract - open val isAvailable: Boolean = true - - /** - * Use this in variants where the feature is not available - */ - @Suppress("unused") // used only in some variants - object Stub : AppScanOptionalFeature() { - override fun getScanContract(): ActivityResultContract = - throw UnsupportedOperationException("Document scan is not available") - - override val isAvailable = false - } -} diff --git a/appscan/build.gradle.kts b/appscan/build.gradle.kts deleted file mode 100644 index 1ade447dfb0b..000000000000 --- a/appscan/build.gradle.kts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.android.library) -} - -android { - namespace = "com.nextcloud.appscan" - - defaultConfig { - minSdk = 28 - compileSdk = 37 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } - - lint.targetSdk = 36 - testOptions.targetSdk = 36 -} - -kotlin.compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") -} - -dependencies { - implementation(libs.appcompat) - implementation(libs.document.scanning.android.sdk) { - exclude(group = "androidx.test", module = "monitor") - } - implementation(libs.ui) -} diff --git a/appscan/src/main/AndroidManifest.xml b/appscan/src/main/AndroidManifest.xml deleted file mode 100644 index 5be0703c66b4..000000000000 --- a/appscan/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/appscan/src/main/java/com/nextcloud/appscan/AppScanActivity.kt b/appscan/src/main/java/com/nextcloud/appscan/AppScanActivity.kt deleted file mode 100644 index 4e0f1f417538..000000000000 --- a/appscan/src/main/java/com/nextcloud/appscan/AppScanActivity.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 Alper Ozturk - * 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.appscan - -import android.app.Activity -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Bundle -import com.nextcloud.android.common.ui.util.extensions.applyEdgeToEdgeWithSystemBarPadding -import com.zynksoftware.documentscanner.ScanActivity -import com.zynksoftware.documentscanner.model.DocumentScannerErrorModel -import com.zynksoftware.documentscanner.model.ScannerResults -import com.zynksoftware.documentscanner.ui.DocumentScanner - -@Suppress("unused") -class AppScanActivity : ScanActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - applyEdgeToEdgeWithSystemBarPadding() - super.onCreate(savedInstanceState) - DocumentScanner.init(this) - addFragmentContentLayout() - } - - override fun onError(error: DocumentScannerErrorModel) { - // TODO pass this from app somehow? - println(error) - } - - override fun onSuccess(scannerResults: ScannerResults) { - val intent = Intent() - - intent.putExtra( - EXTRA_FILE, - scannerResults.transformedImageFile?.absolutePath ?: scannerResults.croppedImageFile?.absolutePath - ) - - setResult(Activity.RESULT_OK, intent) - finish() - } - - override fun onClose() { - setResult(Activity.RESULT_CANCELED) - finish() - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (permissions.contains(android.Manifest.permission.CAMERA) && - (grantResults.isEmpty() || grantResults[0] == PackageManager.PERMISSION_DENIED) - ) { - onClose() - } - } - - companion object { - @JvmStatic - val enabled: Boolean = true - - const val EXTRA_FILE = "file" - } -} diff --git a/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt b/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt deleted file mode 100644 index 2170bc0be9b6..000000000000 --- a/appscan/src/main/java/com/nextcloud/appscan/ScanPageContract.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -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) - } - - override fun parseResult(resultCode: Int, intent: Intent?): String? { - if (resultCode != Activity.RESULT_OK) { - return null - } - return intent?.getStringExtra(AppScanActivity.EXTRA_FILE) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 065eb7b2e8ea..0dc9254f96a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,6 @@ coreVersion = "0.15.0" daggerVersion = "2.60.1" detektGradlePlugin = "1.23.8" disklrucacheVersion = "2.0.2" -documentScannerVersion = "1.3.0" emojiGoogleVersion = "0.24.1" espressoVersion = "3.7.0" eventbusVersion = "3.3.1" @@ -94,7 +93,6 @@ bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bc browser = { module = "androidx.browser:browser", version.ref = "browserVersion" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompatVersion" } core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestVersion" } -document-scanning-android-sdk = { module = "com.github.Hazzatur:Document-Scanning-Android-SDK", version.ref = "documentScannerVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterfaceVersion" } material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCoreVersion" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 26e4b55708b4..80f22edeedaf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,4 +66,4 @@ includeBuild("../android-common") { } */ -include(":app", ":appscan") +include(":app") From d679cfb85ee092511e172c179a3c96ec5139996d Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 24 Sep 2026 11:31:32 +0200 Subject: [PATCH 04/12] feat(scan): Include FairScan via mavenLocal (WIP) FairScan will replace the old scanner component with state-of-the-art CV and UI. This is WIP, because FairScan is (not yet) available on a remote registry. So it is only included via local maven, and the CI builds it via ./gradlew publishToMavenLocal Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Philipp Hasper --- .../action.yml | 44 +++++++++++++++++++ .github/workflows/analysis.yml | 1 + .github/workflows/assembleFlavors.yml | 1 + .github/workflows/check.yml | 1 + .github/workflows/detectWrongSettings.yml | 1 + .github/workflows/garm.yml | 2 + .github/workflows/qa.yml | 4 ++ .github/workflows/unit-tests.yml | 2 + app/build.gradle.kts | 4 ++ app/src/main/AndroidManifest.xml | 10 ++++- gradle.properties | 6 +++ gradle/libs.versions.toml | 3 ++ settings.gradle.kts | 11 +++++ 13 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .github/actions/publish-fairscan-to-maven-local/action.yml diff --git a/.github/actions/publish-fairscan-to-maven-local/action.yml b/.github/actions/publish-fairscan-to-maven-local/action.yml new file mode 100644 index 000000000000..a95492172f3b --- /dev/null +++ b/.github/actions/publish-fairscan-to-maven-local/action.yml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Publish FairScan to mavenLocal" +description: > + Checks out FairScan and publishes it to mavenLocal so a subsequent Gradle build of :app in the + SAME job can resolve org.fairscan:app from mavenLocal(), as configured in settings.gradle.kts. + Must run in the same job as the nextcloud-android build (writes to ~/.m2/repository on the + runner's filesystem) and after a JDK 17-21 has already been set up by the calling workflow + (FairScan's Gradle 8.13 wrapper needs JDK 17-21; JDK 25 fails). + +inputs: + repository: + description: "FairScan repository to check out (owner/repo)." + required: false + # TEMPORARY: points at a fork+branch, not a tagged FairScan release. FairScan isn't published + # anywhere (no JitPack, no release with the new library shape) yet, so CI builds it from + # source. Update this default (and settings.gradle.kts' mavenLocal repository, and + # gradle/libs.versions.toml's fairscanVersion) once FairScan publishes a real, versioned release. + default: "phillab/FairScan" + ref: + description: "FairScan ref to build. See the 'repository' input comment: temporary." + required: false + default: "ph/offer_as_library" + path: + description: "Directory (relative to the job workspace) to check FairScan out into." + required: false + default: "fairscan-src" + +runs: + using: "composite" + steps: + - name: Checkout FairScan + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.ref }} + path: ${{ inputs.path }} + persist-credentials: false + + - name: Publish FairScan to mavenLocal + shell: bash + working-directory: ${{ inputs.path }} + run: ./gradlew --no-daemon publishToMavenLocal diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 5406dd1f93ec..26338183cb7a 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -61,6 +61,7 @@ jobs: with: distribution: "temurin" java-version: 25 + - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Install dependencies run: | sudo apt install python3-defusedxml diff --git a/.github/workflows/assembleFlavors.yml b/.github/workflows/assembleFlavors.yml index d1366f66d511..d2fc3617438a 100644 --- a/.github/workflows/assembleFlavors.yml +++ b/.github/workflows/assembleFlavors.yml @@ -40,6 +40,7 @@ jobs: - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Build ${{ matrix.flavor }} run: | { diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dc3171de27fd..4c2e9d6cf41b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,5 +32,6 @@ jobs: java-version: 21 - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Check ${{ matrix.task }} run: ./gradlew ${{ matrix.task }} diff --git a/.github/workflows/detectWrongSettings.yml b/.github/workflows/detectWrongSettings.yml index aa141bf0f848..532ba30de1bb 100644 --- a/.github/workflows/detectWrongSettings.yml +++ b/.github/workflows/detectWrongSettings.yml @@ -26,5 +26,6 @@ jobs: with: distribution: "temurin" java-version: 21 + - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Detect SNAPSHOT run: scripts/analysis/detectWrongSettings.sh diff --git a/.github/workflows/garm.yml b/.github/workflows/garm.yml index 597648206feb..bfd6b8313426 100644 --- a/.github/workflows/garm.yml +++ b/.github/workflows/garm.yml @@ -50,6 +50,8 @@ jobs: distribution: "temurin" java-version: 21 + - uses: ./.github/actions/publish-fairscan-to-maven-local + - name: Configure gradle daemon run: | mkdir -p $HOME/.gradle diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 42c2cb09a3e5..9e61e4b0df1b 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -37,6 +37,10 @@ jobs: distribution: "temurin" java-version: 25 + - name: Publish FairScan to mavenLocal + if: ${{ steps.check-secrets.outputs.ok == 'true' }} + uses: ./.github/actions/publish-fairscan-to-maven-local + - name: Build QA id: build-qa if: ${{ steps.check-secrets.outputs.ok == 'true' }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b68fb42b9ff2..9d2b7c614b47 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -39,6 +39,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + - uses: ./.github/actions/publish-fairscan-to-maven-local + - name: Run unit tests with coverage run: ./gradlew jacocoTestGplayDebugUnitTest diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f2f8d64e0b4..2e096544fb0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -441,6 +441,10 @@ dependencies { implementation(libs.emoji.google) // endregion + // region FairScan + implementation(libs.fairscan) + // endregion + // region SpotBugs spotbugsPlugins(libs.findsecbugs.plugin) spotbugsPlugins(libs.fb.contrib) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4ecdcd11bd8f..321d2c2b6850 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,8 @@ + tools:ignore="ScopedStorage" + tools:replace="android:maxSdkVersion" /> @@ -131,6 +132,13 @@ android:name="android.content.APP_RESTRICTIONS" android:resource="@xml/app_config" /> + + + + Date: Thu, 24 Sep 2026 22:45:58 +0200 Subject: [PATCH 05/12] feat(scan): Start onboard FairScan component, if external app not installed A separately installed FairScan wins over the built-in scanner, so users can choose to install (a newer version of) FairScan themselves. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Philipp Hasper --- .../android/ui/dialog/DialogFragmentIT.kt | 2 -- .../fragment/OCFileListBottomSheetActions.kt | 8 ++----- .../fragment/OCFileListBottomSheetDialog.kt | 11 +++------- .../ui/fragment/OCFileListFragment.java | 21 +++++++++---------- 4 files changed, 15 insertions(+), 27 deletions(-) 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 8767648d704f..7dc03e9ba20b 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 @@ -432,8 +432,6 @@ class DialogFragmentIT : AbstractIT() { override fun newPresentation() = Unit override fun directCameraUpload() = Unit override fun scanDocUploadFromApp() = Unit - override val isScanDocUploadFromAppAvailable: Boolean - get() = false override fun showTemplate(creator: Creator?, headline: String?) = Unit override fun createRichWorkspace() = Unit } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt index e0d9a66b819d..8869d77e044f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetActions.kt @@ -35,15 +35,11 @@ interface OCFileListBottomSheetActions { fun directCameraUpload() /** - * Offers scanning a document in a supported external app and then upload to the current folder. + * Scans a document (in a separately installed FairScan if present, the built-in scanner + * otherwise) and uploads it to the current folder. */ fun scanDocUploadFromApp() - /** - * @return true, if a supported external app is available for [.scanDocUploadFromApp] - */ - val isScanDocUploadFromAppAvailable: Boolean - /** * 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 d2637f5a6cea..d8bc42b7590b 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 @@ -395,14 +395,9 @@ class OCFileListBottomSheetDialog( dismiss() } - if (actions.isScanDocUploadFromAppAvailable) { - menuScanDocUpload.setOnClickListener { - actions.scanDocUploadFromApp() - dismiss() - } - } else { - menuScanDocUpload.visibility = View.GONE - menuScanDocUploadDivider.visibility = View.GONE + menuScanDocUpload.setOnClickListener { + actions.scanDocUploadFromApp() + dismiss() } menuUploadFiles.setOnClickListener { 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 f9ee5e42cc61..0f8ac22faccd 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 @@ -117,6 +117,8 @@ import com.owncloud.android.utils.PermissionUtil; import com.owncloud.android.utils.theme.ThemeUtils; +import org.fairscan.app.FairScan; + import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -638,18 +640,15 @@ private void showDirectCameraUploadAlertDialog(FileDisplayActivity fileDisplayAc @Override public void scanDocUploadFromApp() { - requireActivity().startActivityForResult( - scanIntentExternalApp, - FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME); - } + final Activity activity = requireActivity(); - @Override - public boolean isScanDocUploadFromAppAvailable() { - var context = getActivity(); - if (context == null) { - return false; - } - return scanIntentExternalApp.resolveActivity(context.getPackageManager()) != null; + // A separately installed FairScan wins over the built-in scanner, so users can pick up + // a newer FairScan release before Nextcloud itself updates the bundled version. + final Intent intent = scanIntentExternalApp.resolveActivity(activity.getPackageManager()) != null + ? scanIntentExternalApp + : FairScan.scanToPdfIntent(activity); + + activity.startActivityForResult(intent, FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME); } @Override From 5ccd8495925cd2a7c3f0292320c5090292d2f02a Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Wed, 30 Sep 2026 07:08:54 +0000 Subject: [PATCH 06/12] fix(scan): finish embedded FairScan scan-to-PDF upload Route both the external-app and embedded FairScan scan results through a single field-level ActivityResultLauncher. The upload uses FileUploadHelper directly, instead of routing back to FileDisplayActivity's requestUploadOfContentFromApps() because: 1. The suggested new pattern is ActivityResultLauncher, which didn't go well with the intent-based pattern of FileDisplayActivity. As a reference, AlbumItemsFragment also does its own upload. 2. FileDisplayActivity uses UriUploader which explicitly prevents local files from being uploaded, cf. isSensitiveUri(). But in case of the document scanning, the file is created internally, in the /fairscan folder, and uploaded from there. Removed the now-unused REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME, which was originally introduced for renaming the external FairScan file. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Philipp Hasper --- .../ui/activity/FileDisplayActivity.kt | 28 +------ .../ui/fragment/OCFileListFragment.java | 84 +++++++++++++++++-- 2 files changed, 83 insertions(+), 29 deletions(-) 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 0af3787c253b..6c0cb6c16dc9 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 @@ -46,7 +46,6 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView -import androidx.core.util.Function import androidx.core.view.MenuItemCompat import androidx.core.view.ViewCompat import androidx.core.view.isVisible @@ -171,12 +170,10 @@ 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 @@ -1053,13 +1050,10 @@ 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_AUTO_RENAME - ) && + requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS && (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE) ) { - requestUploadOfContentFromApps(requestCode, resultCode, data) + requestUploadOfContentFromApps(resultCode, data) } else if (data != null && requestCode == REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM && ( @@ -1205,7 +1199,7 @@ class FileDisplayActivity : } } - private fun requestUploadOfContentFromApps(requestCode: Int, resultCode: Int, contentIntent: Intent) { + private fun requestUploadOfContentFromApps(resultCode: Int, contentIntent: Intent) { val streamsToUpload = ArrayList() if (contentIntent.clipData != null && (contentIntent.clipData?.itemCount ?: 0) > 0) { @@ -1227,17 +1221,6 @@ 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, @@ -1249,7 +1232,7 @@ class FileDisplayActivity : behaviour, false, // Not show waiting dialog while file is being copied from private storage null, // Not needed copy temp task listener - fileDisplayNameTransformer + null ) uploader.uploadUris() @@ -3366,9 +3349,6 @@ 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 - private val TAG: String = FileDisplayActivity::class.java.getSimpleName() const val TAG_LIST_OF_FILES: String = "LIST_OF_FILES" 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 0f8ac22faccd..8521221d776e 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 @@ -19,13 +19,15 @@ import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.Parcelable; import android.text.TextUtils; -import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; @@ -45,10 +47,13 @@ import com.nextcloud.client.device.DeviceInfo; import com.nextcloud.client.di.Injectable; import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.systembars.SystemBarBackgroundCallbacks; import com.nextcloud.client.utils.Throttler; import com.nextcloud.common.NextcloudClient; +import com.nextcloud.model.OCUploadLocalPathData; import com.nextcloud.ui.fileactions.FileAction; import com.nextcloud.ui.fileactions.FileActionHandler; import com.nextcloud.ui.fileactions.FileActionHost; @@ -111,6 +116,7 @@ import com.owncloud.android.ui.events.SearchEvent; import com.owncloud.android.ui.fragment.helper.ParentFolderFinder; import com.owncloud.android.ui.helpers.FileOperationsHelper; +import com.owncloud.android.ui.helpers.UriUploader; import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; @@ -133,6 +139,8 @@ import javax.inject.Inject; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -234,6 +242,11 @@ public class OCFileListFragment extends ExtendedListFragment implements private static final Intent scanIntentExternalApp = new Intent("org.fairscan.app.action.SCAN_TO_PDF"); + private final ActivityResultLauncher scanLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> handleScanResult(result.getResultCode(), result.getData()) + ); + @Inject DeviceInfo deviceInfo; protected enum MenuItemAddRemove { @@ -644,11 +657,72 @@ public void scanDocUploadFromApp() { // A separately installed FairScan wins over the built-in scanner, so users can pick up // a newer FairScan release before Nextcloud itself updates the bundled version. - final Intent intent = scanIntentExternalApp.resolveActivity(activity.getPackageManager()) != null - ? scanIntentExternalApp - : FairScan.scanToPdfIntent(activity); + boolean separateApp = scanIntentExternalApp.resolveActivity(activity.getPackageManager()) != null; + Intent scanIntent = separateApp ? scanIntentExternalApp : FairScan.scanToPdfIntent(activity, true); + + scanLauncher.launch(scanIntent); + } + + private void handleScanResult(int resultCode, @Nullable Intent data) { + Uri pdfUri = FairScan.pdfUriFromResult(resultCode, data); + if (pdfUri == null) { + return; + } + + uploadScannedPdf(pdfUri); + } - activity.startActivityForResult(intent, FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME); + private void uploadScannedPdf(@NonNull Uri pdfUri) { + if (!(getActivity() instanceof FileActivity fileActivity)) { + Log_OC.e(TAG, "Activity is null, cant upload scanned document"); + return; + } + + final var user = fileActivity.getUser(); + if (user.isEmpty()) { + Log_OC.e(TAG, "User not exist, cant upload scanned document"); + return; + } + + final OCFile currentDir = getCurrentFile(); + final String remotePath = currentDir != null ? currentDir.getRemotePath() : ROOT_PATH; + + if (ContentResolver.SCHEME_FILE.equals(pdfUri.getScheme())) { + uploadEmbeddedScannedPdf(user.get(), pdfUri, remotePath); + } else { + uploadExternalScannedPdf(fileActivity, user.get(), pdfUri, remotePath); + } + } + + private void uploadEmbeddedScannedPdf(@NonNull User user, @NonNull Uri pdfUri, @NonNull String remotePath) { + final String localPath = pdfUri.getPath(); + if (localPath == null) { + Log_OC.e(TAG, "Scanned document has no local path, cant upload: " + pdfUri); + return; + } + + final String remoteFilePath = remotePath + FileOperationsHelper.getTimestampedFileName(".pdf"); + OCUploadLocalPathData data = OCUploadLocalPathData.Companion.forFile( + user, + new String[] { localPath }, + new String[] { remoteFilePath }, + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE + ); + FileUploadHelper.Companion.instance().uploadNewFiles(data); + } + + private void uploadExternalScannedPdf(@NonNull FileActivity fileActivity, @NonNull User user, @NonNull Uri pdfUri, + @NonNull String remotePath) { + new UriUploader( + fileActivity, + new ArrayList(List.of(pdfUri)), + remotePath, + user, + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + false, + null, + null + ).uploadUris(); } @Override From 4533439a6f8d2108f24d3aeee176c367f2e0db77 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Wed, 14 Oct 2026 19:12:41 +0200 Subject: [PATCH 07/12] Manually added key for dependency sh.calvin.reorderable Running from gradle somehow sent it into an endless loop. This key was fetched from https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0xAB07D8E9F243E3F6F933B4FAE886D43F9B6F84F9 and added Signed-off-by: Philipp Hasper --- gradle/verification-keyring.keys | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index aa089d3fdafb..81ff68bb624b 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -9376,3 +9376,18 @@ byp2Ws27l1oNhMexztBivHz1OLxGJY8odrCtuxK3JMllDdln/HHdwrp6h7SDRdxR 6llX74zIdctZVsii8eJHvA== =DZ0u -----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: Hockeypuck 2.2 +Comment: + +xjMEZVrPUxYJKwYBBAHaRw8BAQdAFtim0TLdBCDZca6SP9cArWjKwSo5PxZR1LcS +HzNgwZHNG0NhbHZpbiBMaWFuZyA8bWVAY2FsdmluLnNoPsKZBBMWCgBBFiEEqwfY +6fJD4/b5M7T66IbUP5tvhPkFAmVaz1MCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsC +BBYCAwECHgcCF4AACgkQ6IbUP5tvhPnMDwEA8DW39bCnb02L310LRWMgXPen0Jyg +YoIyFUw3s+sKEHwBAM9s+o5zZhXdJoZuzuZj9IEwKK7xsf+ieXJ8OEEJ3vgBzjgE +ZVrPUxIKKwYBBAGXVQEFAQEHQCf5jHOcZ2XPQQZjqpDy3I3hg50eC42rlJp6rAh6 +l5ByAwEIB8J+BBgWCgAmFiEEqwfY6fJD4/b5M7T66IbUP5tvhPkFAmVaz1MCGwwF +CQWjmoAACgkQ6IbUP5tvhPnmlQD/cYdzuob+kcFd0Og9T4VZ0eA/T7vZOUCnC5dQ +UgoqnwUA/inddpH+5Rxs2+xUJ+lZVASRWiFj9XwRDRFG+BFmAocM +=7RNZ +-----END PGP PUBLIC KEY BLOCK----- From 0398cf701af2a63ba6f5be6d08008c79ad41ea79 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Wed, 14 Oct 2026 19:58:48 +0200 Subject: [PATCH 08/12] Updated dependency verification data Ran `./gradlew --write-verification-metadata pgp,sha256 --export-keys help` Signed-off-by: Philipp Hasper --- gradle/verification-keyring.keys | 152 ++++++++++++++++++++++++++++--- gradle/verification-metadata.xml | 98 ++++++++++++++++++++ 2 files changed, 235 insertions(+), 15 deletions(-) diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index 81ff68bb624b..af64a8e82b28 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -2138,6 +2138,51 @@ u8veAZEXwuJGYHE= =gx4v -----END PGP PUBLIC KEY BLOCK----- +pub C0E2EB10E7B60FAD +uid Mike Penz (OpenSource library signing) + +sub 488E1BD9D30006C0 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBF4Vpt4BEACv9ZDb6xRuHRJTVJuDRG+A0sjAZJjUy6NUOEtVNUqGZYT0NNqa +fNLXw/79Y0RmmG1Q9v+vmE242IyO5AamML/Lz57jO6rQp3Wmg0HH6TofMV5iKGNX +dlt2uYi5eWrzoiE5hG3RlP+7WV7lPqoUHx2ES20ilIQxOG307mnNJmy0NzupBHV9 +B7vMPgo2XYnG3Q/B/9n9IO/Uk2m9ejSUWMqe6gWjLmhVdl8TgXWCqhhMach/MU6q +62nOUvbp5VzHks2d9CyRSALMlc2Pj8fdBREt3pTmMW5SbnrVFcQBd6NojuBjD/Az +//MXy6JjAVbtu2YoN82TAPhOmj2dAHbOrdef/i/IaIPPdokpMogHZVuu0TLPWn71 +d7IJtw1eVLAgj2B5ydG9fLEmf3oiI4T9OSSLq94f4ijOQH3n0hx/JO14va1Qq+jJ +JK7nIMkusFpsDAhV6oKeYHu9L4FPc408Beuj2ZALBw2ZlItX1uQQugtG3RmQRjr8 +aEUanpptdSHAy8aP9EdyVdOEIiljJVXZWjlcHuRuIZBbCeffnTJpnEkQqEGyndZL +C0XlYEt+Zwjio4opNcdwVZ12TvQq7NbUP69o1NTOXeZapZEZ9n8o2wiaX4PhFgry +1XX4aCRNXAmdyBQvZ0NkzmEx4pK26tNozNZyVm5882pbZtGgum3mrsa5SwARAQAB +tDtNaWtlIFBlbnogKE9wZW5Tb3VyY2UgbGlicmFyeSBzaWduaW5nKSA8bWlrZXBl +bnpAZ21haWwuY29tPs7BTQReFabeARAAuMzYa/C7VQLdR9mVE/n0kFiywX8J4dCt +vrpjyi/JU7sGZWTEAHoq/DuYAXn9eFbyzpmVRuezr1jfYkKGINxRTqowuG0mHOKJ +snzkB64bObYVaAQJuMQozNswrLofzmcuvPC9fTHMXsQypMnaF6mFzGOwRGpkoCP/ +BSBPn73mSkDcLCRAZZrdPNkQoICtRpNvZ2uMEvjhIGZd0HzT1bGd2FDbNIdGT3N9 +2s813rjRmvPk9xwCzqDFQDliwjHhGsP7vOcz1EJue9px5hFz7AQTNZOqVhHIucW4 +vDpKlpBSE2ofl0DMt9QhdAKd9TWvdogaaryTBO2CwBqoXtgSTcyf17yEP2q4OCwf +9oOELTJZaPGDnSOb1ooH2arwrSRoblxZhtnswq+i+Y7/JKoY5u5HAKb7ARzg6GEL +jWxMKgcHoaWjYsqxafzSrwlaAc6sy8pPthjSKPkDtMkPIvtXZMJhnpwWoJWvJN3y +xT+TwGex99MXOsoZvgGjJ6pgSOEpRtDhuaEmqi39h+uETZHiBQBhXOmRJyNr2XB3 +LS2fsHVReUzv0VLktkGsHxibeVmn8PeJf+vg2USUekBXtRSb2V4uTGp79eSvghns +ME3YfEFVU/iNRc9/oXQvFqT8kR6VN0TtpwR73HpeszmsYO2Em0gH21H5VZhVKFYM +bepll0I/JVEAEQEAAcLBfAQYAQoAJhYhBOBPygdscW2mdzVl6sDi6xDntg+tBQJe +FabeAhsMBQmWg7CAAAoJEMDi6xDntg+tfqsP/iaW5Z4u20A8gKr/E/MGxqrag1VR +J339PTaLc91tovE8YVOzHWutEv324NVHzL0DLaeBUbqZx+m3w2AyE3FxHLjdtXyz +w/n1VWJbmi4xhozSxJVbUZ9Uzt3W3E6DXwa9Pgsrx9RTPy+l8rU+sMFY5dMxDDGd +odVBCdmJcgeKtVSs5cv3pN5YUvtH1UOCLc1ohpEC32wOrhJWZM89H9/R3jUds9c0 +1kOpoW5cdv81hguLUCjr+bO9Dc/0LJQI5b1HnZd3xbDDtxtlMTVW6dz+58DCpnD8 +r0D/Yc0Z/N+ngRX4wzSdEdjCf7atY8kIcRyFyQDoHSrAFxLbEvCESjfzSMBVAtGD +RYFlPnmR0kfCaChmn0k4Goxce7wDtox4fkQMO8Lx/+DDxNhVEGTO7gDKjmz0lfOA +rKLghBhAmUK493D5T0GsjgF3dV4FTuieVTCCmaE2mc4qInELVZ5GAn8Aas6dj63D +88JdIES6TE6eBr5FShvJZIHxRl7Uzsx0N5eO89Et/5TWJ5zsPe3U6MkHY5yhFztM +ijiQ6PVAbD/I0J7Y+cDyOxBvKA9+DyvVGwGlgU4EU2MZVy21G+vwoTISlqEUB9+D +AguFGb+9+c3FVUpU7QAupW8fU4tAkUZbiZkGHXN7wADhiSCilEk8O1gTmNASaUcz +e31IKwLC/NOHo4/A +=SROI +-----END PGP PUBLIC KEY BLOCK----- + pub C3BAB45F4AF71FAB uid Yang Song @@ -2942,6 +2987,50 @@ tJscrv0/jtIwo5zy8SqldnhB6g== =OReJ -----END PGP PUBLIC KEY BLOCK----- +pub D7662013FA1A5596 +uid skydoves (Jaewoong Eum) + +sub CBC29FFF4999D93A +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBFzX5PUBEACaGSIYimcJyfAqK85lUc2cxCG8gnT80AfnEpNtoxixRsYGgwmm +MVAOXBb9CLbiMNV++AYRLuANXoqbHbF6yLRf4qJyXHOrnQltiA6WYxyDupVPFa4K +OF/gFskG8T2S+pY6YPXxt66w3lLR4hh9zuJCcmJafdzL9Lg/h7yFQzSXp9B6qgI2 +MAl3tPwlVxiTRFX8uMfp+t40skv3XkjCHEVj6qah1VJyiwqmd+sl52XaZ+3/URkw +KjOShP33tFK5Z9E1vI8fPHO7Bh6uR+/Pho40TQp7hBCK/EIbMJAJ72zJEVUiR3RW +tWoTkovrFxDsqjPOAxf2pLkwG92wwlYhJlrwN8ViDrGGwMGeR7vfQC0xqkU8OxSF +m/x4XW1lRAOa2gExUF5bDNlFWTuTfDXn9If4yin7NB0fMvyM2H0LWkS8hL9b/j8J +DkgtEmYrXqhZYfCsmM2foiCqm66sJatN5G1ul1yGzV/v3TU2wGJP4L6fjdBc6xdE +DfG/nfvng2R7m5PMA1lk+q2wODLff9yHb72jYhKBmpSIPGlyCKuLdrNTj8+V7Brx +sLBLx26vy046+fOnED2mnU6hnQByZhM19zEZ3GQR6w+rivPH7ewjz/Qqmm1Vv04z +ZbgN0JfGTQEwVitNP48P8OVsOwyN4i51vXhBv5tu+Q0KS3dk3Uvv1JwyHwARAQAB +tCxza3lkb3ZlcyAoSmFld29vbmcgRXVtKSA8c2t5ZG92ZXNAbmF2ZXIuY29tPs7B +TQRc1+T1ARAAvZXWe7ebrisRHaTVHi24UMD+bN9dI5TCqVN+WlaEoaFDk6/g/h7n +Aya1uKTBPftItv5mJiPsVye2JG8GD+yklh9a5wz3GOVt4lbXmHh+T/NFsAyT0k1n +8qpSdo3F0gJlL6D/2uTNTCvHzs+xBzGkNU4dBoHJPj83Ubu/Saoaz7rc8Aqe5s3y +jlurEfEKXQ2CRl6X/80+QnGpYmfwiPrGGiz17mMU+9DY0kDcPOtmeVL5fIqmVZZr +Ha6nC7teXWN2IZQroPXM6esyDZ7UCRtuGH74vPeOf/bfGwAgz7VvlWD4qTBd2qLa +tMEpSv+zwM064+DDswXuMMK2PabHOjxZd/GHB98jSF91WyWdSTajzkcm5pF6Gd99 +69st+uOUzwCYiyukE/XMpWLzfF+FtTty75k57Edrun+ykr9vIEyRpgo1TICnstVk +NLNt22xayw9RCXz5Uv8dhf7xLi0mLGFWoW8JRub8ChKKxX1AO9seFtp0KKPy5CCg +dntIF5/cKIG2c8BDlNXE6hZUI2a3c9a0gtMntC53YAtNEn+2REIANadhOvhbdYvK +cu0zvXlSo1DgSEjNwYRBGwjMOKgUZ4qer71XI1LnyKu+uwLrBs10tIH76Pr//Tuz +cZ8cxayx6SPFfBd8LrdOz3rwimkbG/UNTMh9BsF9V1OK6eKd4nVaI5cAEQEAAcLB +XwQYAQIACQUCXNfk9QIbDAAKCRDXZiAT+hpVls1GD/4hBAZbjrMkGIg5MipHI/h/ +n2LQPu8fhd9d3N2oFLFXn/0iXFesaob9O5QprOMP70EEOJN6WsC3OlqrlposNKES +kNXp9mhIGn8yG4oLL44hwNJQP9brdVqumsh3ehUCaLJQfbUE3xGx2XPwpgxnULPx +Q7RXdw73/JSoJqa8j0/X0TmdQQsJVeJ//cWOKlchdGh1aPjma8tJKELYNCpNZkAI +tH8lYaEg7bistSndJZas136ECaIujk2Wt1CJLKdsdXWn9UOiLvA38HbRKAtAbnmi +VdzvwJQDKbqGwWzfH+bAjgGto8MFM3+nmrqQ6JhYISQG7nWFQAgDiCX5opw1GWFR ++gFy6vzoIAcU8jt/F4n7erxDuQiDRcVLxPAwBI79IlsL4qHBuU95OQPDxFkHt+hC +WLxvZ96HZczlYmpNMTXjum8j8t0/EeVyzYQAVTjheAlSmbPa/zB8MpckwLc2kO3h +fBiNJDfXoptaZ4H6DFxNDHf9sD+d31nWBrJoRti7a8IQiC7QswSFdYbL5RH9huuo +wOTnyt/xBwGRMvYkz7pwGvO8wya3KaBYefL2/UUcsK1dVJm9H6DAQ+sCKaDBaO1n +i27UpDdLHUHr4irAPZs+6qWHOYVFFy8uBLah7rUvV69Lpt5JIPLaiS7M3kgig2YG +MD9W3Ckbgcahr/9YO8OFLg== +=I2kh +-----END PGP PUBLIC KEY BLOCK----- + pub D7C92B70FA1C814D uid Matthew Sicker (Signing Key) @@ -3312,6 +3401,39 @@ u4awI7kDfdqz1V5s5prLGlkoPhACUxRZeznT3Js7NVE= =ZRYf -----END PGP PUBLIC KEY BLOCK----- +pub E0F9DC177239CD8A +sub 12F64A1C91895516 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsDNBGWNTxIBDADmE/W3AVPhGWIHBGoXk4gwN0htHmclNqfoL5ghNS0GrMm6B99J +tRExRf45/cqo6pfWgZmPoxB/6BUBU96hb1pAdOPGjq4AyDgDwg9W/yvTPTRvnsds +SmIE35YlYN8LDXVgWAKEVOmNFOgFw+xDcDvKCUTrBq02cSfMMh8jfrbylCT2U0bD +hDqlOEJ0pTUmOsnNr0+6ds4FHCJneNR4jQKkI5g8qAALPKNRr66KTR5qckeL90xi +KMtbgzd4nBClfMfxrV7Ysw/xmriKqLK8q0A9A1xQwK5JYEIab6viVIvUD3IlWE0T +HLoPQQZGuYzICI4UHBQWN/aRA1HWruAL8ms4I+XS63r8LiDDyYgXlzDr0ZngB82S +RUpnJX9UEIZt6R0mURLJ8EoUfnP/wpb7rNeepuyvXDwQ9EaGXEUoI7ldJjWUeVpw ++zoPHFieXG2LjBzrhI5sIzd6FSt1UbvsAQnkuIZTGhDAlI4SkeyArFLgq1vsaRwv +bYoUpoyPyf2+158AEQEAAc7AzQRljU8SAQwAtmgIFYFPuTziWJvkrocnER2eSRun +Zp5DCxb+aGolVS521bebxcTXv+jcgpuzJGCXsU4yv8jQY2tgwXq+13nPw1GJdPn0 +TiqXNZRgY5/cwVVl7haawUXoXt8F/alH3ruyy4AUKyZIk1fO6Ald/LMEhDJKCr0z +s0YLqk8d1gG7y1Y9ORwwa1DIL+Y+NugmKOqzCLhOQ4ojvMHQ1xq/EgP91pgErznV +k65jx0+vKcrdkZiPQfZDIvDeXG/mu8UB5IzjUXbShG9T07VGmd87q9I8ZbGCwOR0 +GT3bs5sdX/IMUQs7HZUOERum5eDJ/e5bo9iWBY9ffGISKJQQ5jZsS1rwr2ioRjO3 +vfiWrT+0u12sxdGDxuaoWZkqmqcssmSCSiAKaWEtba6IZdm0V1ZuyUbODp9+8DqC +JsnMmMaGXC6GlkEdHW/0DkdqnRANRZ6UhU0q+pJExlc5rQichgpm0mk77HRsCDYN +Ga9ZUj/nJwJLotJFlRXVIIwljZ+wJfyWgRdvABEBAAHCwPwEGAEKACYWIQTtqFiD +iTwsoJgdWlTg+dwXcjnNigUCZY1PEgIbDAUJA8JnAAAKCRDg+dwXcjnNinhbC/9I +oYxAERxkBllxY5FaIWvVn35v3axQRCvqz8v1lgPL2LrIkokcmyZENbMoIaC25E8B +JKgQjJtLJKJzE5sOLQV/rYnHUSVwTn+mm8iyNu3VhRtfoh3uq4S+ZmFixs76xFla +em5tO9h/7UBH29OmDuYm3eQAZF/ASmXkvRuEOx3WtD6YFNUQ3V61t46/1PlIoZ9x +EA6WkrHL8z4+HEaL+ftCVGwL+F8q2Hs5UI9C/dnESYwenYOmMRssgSqp3+8T+00x +rWdGY/mNAUCTlSoAjsE5vFMz3dJZKMdpuuIrZMnTMh1Em0wa9+9yz8dzOicp02ld +M9fgmoXl4za2RZM6WtR74KYfroAu5YZm9sNkCPmT/fqJYotA2OJsq7e3x5PQKVP1 +BoYmenmPgRcrfZVJALQPKiunh9l5VYEq7DgTJ+520XG7brQKKxnp8wpIkXVcWSqz +ksd/LwATBPYf7rfKuxEWDfCy294FXhfUSAIpYlHvMSNvxqots45yDwNfSWpwJ/Y= +=nPBW +-----END PGP PUBLIC KEY BLOCK----- + pub E126833F9CF64915 sub C64121F5021754AB -----BEGIN PGP PUBLIC KEY BLOCK----- @@ -3580,6 +3702,21 @@ kDPpOUpVEh9FB0MrCNxY+0pa =w5DF -----END PGP PUBLIC KEY BLOCK----- +pub E886D43F9B6F84F9 +uid Calvin Liang + +sub 70B51E4211514239 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEZVrPUxYJKwYBBAHaRw8BAQdAFtim0TLdBCDZca6SP9cArWjKwSo5PxZR1LcS +HzNgwZG0G0NhbHZpbiBMaWFuZyA8bWVAY2FsdmluLnNoPs44BGVaz1MSCisGAQQB +l1UBBQEBB0An+YxznGdlz0EGY6qQ8tyN4YOdHguNq5SaeqwIepeQcgMBCAfCfgQY +FgoAJhYhBKsH2OnyQ+P2+TO0+uiG1D+bb4T5BQJlWs9TAhsMBQkFo5qAAAoJEOiG +1D+bb4T55pUA/3GHc7qG/pHBXdDoPU+FWdHgP0+72TlApwuXUFIKKp8FAP4p3XaR +/uUcbNvsVCfpWVQEkVohY/V8EQ0RRvgRZgKHDA== +=vhh3 +-----END PGP PUBLIC KEY BLOCK----- + pub E93671C7272B7B3F uid Rolf Lear (JDOM) (Used to sign JDOM Packages) @@ -9376,18 +9513,3 @@ byp2Ws27l1oNhMexztBivHz1OLxGJY8odrCtuxK3JMllDdln/HHdwrp6h7SDRdxR 6llX74zIdctZVsii8eJHvA== =DZ0u -----END PGP PUBLIC KEY BLOCK----- ------BEGIN PGP PUBLIC KEY BLOCK----- -Version: Hockeypuck 2.2 -Comment: - -xjMEZVrPUxYJKwYBBAHaRw8BAQdAFtim0TLdBCDZca6SP9cArWjKwSo5PxZR1LcS -HzNgwZHNG0NhbHZpbiBMaWFuZyA8bWVAY2FsdmluLnNoPsKZBBMWCgBBFiEEqwfY -6fJD4/b5M7T66IbUP5tvhPkFAmVaz1MCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsC -BBYCAwECHgcCF4AACgkQ6IbUP5tvhPnMDwEA8DW39bCnb02L310LRWMgXPen0Jyg -YoIyFUw3s+sKEHwBAM9s+o5zZhXdJoZuzuZj9IEwKK7xsf+ieXJ8OEEJ3vgBzjgE -ZVrPUxIKKwYBBAGXVQEFAQEHQCf5jHOcZ2XPQQZjqpDy3I3hg50eC42rlJp6rAh6 -l5ByAwEIB8J+BBgWCgAmFiEEqwfY6fJD4/b5M7T66IbUP5tvhPkFAmVaz1MCGwwF -CQWjmoAACgkQ6IbUP5tvhPnmlQD/cYdzuob+kcFd0Og9T4VZ0eA/T7vZOUCnC5dQ -UgoqnwUA/inddpH+5Rxs2+xUJ+lZVASRWiFj9XwRDRFG+BFmAocM -=7RNZ ------END PGP PUBLIC KEY BLOCK----- diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f44868dc8cc4..86ed505ef81b 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -22,6 +22,8 @@ + + @@ -55,12 +57,14 @@ + + @@ -85,6 +89,7 @@ + @@ -92,6 +97,7 @@ + @@ -107,6 +113,7 @@ + @@ -379,6 +386,7 @@ + @@ -453,6 +461,7 @@ + @@ -546,6 +555,7 @@ + @@ -558,6 +568,7 @@ + @@ -1651,6 +1662,11 @@ + + + + + @@ -7249,6 +7265,11 @@ + + + + + @@ -25290,6 +25311,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -25639,6 +25700,14 @@ + + + + + + + + @@ -29935,6 +30004,14 @@ + + + + + + + + @@ -30460,6 +30537,14 @@ + + + + + + + + @@ -33552,6 +33637,19 @@ + + + + + + + + + + + + + From 278f86f4ff421bc97fad12adc4dde3f2da79566d Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 18 Oct 2026 15:42:54 +0200 Subject: [PATCH 09/12] feat(scan): Using library fork of FairScan, available on Jitpack Assisted-by: ClaudeCode:claude-sonnet-5.5 Signed-off-by: Philipp Hasper --- .../action.yml | 44 ------------------- .github/workflows/analysis.yml | 1 - .github/workflows/assembleFlavors.yml | 1 - .github/workflows/check.yml | 1 - .github/workflows/detectWrongSettings.yml | 1 - .github/workflows/garm.yml | 2 - .github/workflows/qa.yml | 4 -- .github/workflows/unit-tests.yml | 2 - gradle/libs.versions.toml | 5 +-- settings.gradle.kts | 11 ----- 10 files changed, 2 insertions(+), 70 deletions(-) delete mode 100644 .github/actions/publish-fairscan-to-maven-local/action.yml diff --git a/.github/actions/publish-fairscan-to-maven-local/action.yml b/.github/actions/publish-fairscan-to-maven-local/action.yml deleted file mode 100644 index a95492172f3b..000000000000 --- a/.github/actions/publish-fairscan-to-maven-local/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - -name: "Publish FairScan to mavenLocal" -description: > - Checks out FairScan and publishes it to mavenLocal so a subsequent Gradle build of :app in the - SAME job can resolve org.fairscan:app from mavenLocal(), as configured in settings.gradle.kts. - Must run in the same job as the nextcloud-android build (writes to ~/.m2/repository on the - runner's filesystem) and after a JDK 17-21 has already been set up by the calling workflow - (FairScan's Gradle 8.13 wrapper needs JDK 17-21; JDK 25 fails). - -inputs: - repository: - description: "FairScan repository to check out (owner/repo)." - required: false - # TEMPORARY: points at a fork+branch, not a tagged FairScan release. FairScan isn't published - # anywhere (no JitPack, no release with the new library shape) yet, so CI builds it from - # source. Update this default (and settings.gradle.kts' mavenLocal repository, and - # gradle/libs.versions.toml's fairscanVersion) once FairScan publishes a real, versioned release. - default: "phillab/FairScan" - ref: - description: "FairScan ref to build. See the 'repository' input comment: temporary." - required: false - default: "ph/offer_as_library" - path: - description: "Directory (relative to the job workspace) to check FairScan out into." - required: false - default: "fairscan-src" - -runs: - using: "composite" - steps: - - name: Checkout FairScan - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ inputs.repository }} - ref: ${{ inputs.ref }} - path: ${{ inputs.path }} - persist-credentials: false - - - name: Publish FairScan to mavenLocal - shell: bash - working-directory: ${{ inputs.path }} - run: ./gradlew --no-daemon publishToMavenLocal diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 26338183cb7a..5406dd1f93ec 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -61,7 +61,6 @@ jobs: with: distribution: "temurin" java-version: 25 - - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Install dependencies run: | sudo apt install python3-defusedxml diff --git a/.github/workflows/assembleFlavors.yml b/.github/workflows/assembleFlavors.yml index d2fc3617438a..d1366f66d511 100644 --- a/.github/workflows/assembleFlavors.yml +++ b/.github/workflows/assembleFlavors.yml @@ -40,7 +40,6 @@ jobs: - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Build ${{ matrix.flavor }} run: | { diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4c2e9d6cf41b..dc3171de27fd 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,6 +32,5 @@ jobs: java-version: 21 - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Check ${{ matrix.task }} run: ./gradlew ${{ matrix.task }} diff --git a/.github/workflows/detectWrongSettings.yml b/.github/workflows/detectWrongSettings.yml index 532ba30de1bb..aa141bf0f848 100644 --- a/.github/workflows/detectWrongSettings.yml +++ b/.github/workflows/detectWrongSettings.yml @@ -26,6 +26,5 @@ jobs: with: distribution: "temurin" java-version: 21 - - uses: ./.github/actions/publish-fairscan-to-maven-local - name: Detect SNAPSHOT run: scripts/analysis/detectWrongSettings.sh diff --git a/.github/workflows/garm.yml b/.github/workflows/garm.yml index bfd6b8313426..597648206feb 100644 --- a/.github/workflows/garm.yml +++ b/.github/workflows/garm.yml @@ -50,8 +50,6 @@ jobs: distribution: "temurin" java-version: 21 - - uses: ./.github/actions/publish-fairscan-to-maven-local - - name: Configure gradle daemon run: | mkdir -p $HOME/.gradle diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 9e61e4b0df1b..42c2cb09a3e5 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -37,10 +37,6 @@ jobs: distribution: "temurin" java-version: 25 - - name: Publish FairScan to mavenLocal - if: ${{ steps.check-secrets.outputs.ok == 'true' }} - uses: ./.github/actions/publish-fairscan-to-maven-local - - name: Build QA id: build-qa if: ${{ steps.check-secrets.outputs.ok == 'true' }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9d2b7c614b47..b68fb42b9ff2 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -39,8 +39,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - - uses: ./.github/actions/publish-fairscan-to-maven-local - - name: Run unit tests with coverage run: ./gradlew jacocoTestGplayDebugUnitTest diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e99763f73308..212a843d48d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,8 +8,6 @@ androidImageCropperVersion = "4.7.0" androidLibraryVersion ="dedf04f10c5e4f400baf9760993b5a2c7561ad58" androidOpensslVersion = "3.5.6.1" androidPluginVersion = "9.4.1" -# Temporarily using the "-SNAPSHOT" suffix for dev testing via to mavenLocal. Remove once published online -fairscanVersion = "2.3.0-SNAPSHOT" androidsvgVersion = "1.4" androidxTestVersion = "1.7.0" annotationVersion = "1.11.0" @@ -33,6 +31,7 @@ espressoVersion = "3.7.0" eventbusVersion = "3.3.1" exifinterfaceVersion = "1.4.2" ezVcardVersion = "0.12.2" +fairscanVersion = "v2.3.0-11-gc1517c5-lib-1" fbContribVersion = "7.7.4" findsecbugsPluginVersion = "1.14.0" firebaseMessagingVersion = "26.0.0" @@ -128,7 +127,7 @@ ext-strikethrough = { module = "io.noties.markwon:ext-strikethrough", version.re ext-tables = { module = "io.noties.markwon:ext-tables", version.ref = "markwonVersion" } ext-tasklist = { module = "io.noties.markwon:ext-tasklist", version.ref = "markwonVersion" } ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard", version.ref = "ezVcardVersion" } -fairscan = { module = "org.fairscan:app", version.ref = "fairscanVersion" } +fairscan = { module = "com.github.phillab.fairscan-library-fork:scan-ui", version.ref = "fairscanVersion" } findsecbugs-plugin = { module = "com.h3xstream.findsecbugs:findsecbugs-plugin", version.ref = "findsecbugsPluginVersion" } fb-contrib = { module = "com.mebigfatguy.fb-contrib:fb-contrib", version.ref = "fbContribVersion" } firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessagingVersion" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 63a75a62ed10..80f22edeedaf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,17 +38,6 @@ dependencyResolutionManagement { } mavenCentral() maven("https://jitpack.io") - // FairScan isn't published to JitPack yet. Until then, it's consumed from mavenLocal, - // published there manually and explicitly, same as any other binary dependency you're - // developing against locally before it has a real release: - // ./gradlew publishToMavenLocal - // Re-run that whenever the FairScan checkout is updated to a different commit/tag. Once - // FairScan publishes to JitPack for real, this mavenLocal entry goes away and the - // coordinate in gradle/libs.versions.toml switches to the JitPack one - nothing else - // about how :app depends on it changes. - mavenLocal { - content { includeGroup("org.fairscan") } - } } } From a7c984fbf102e892c5de9839bde391b9a96a12d3 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Tue, 20 Oct 2026 09:14:35 +0200 Subject: [PATCH 10/12] Added verification metadata for fairscan library Jitpack artifact 1. Ran `gradlew --write-verification-metadata pgp,sha256 --export-keys help` and compared the hashes for the .jar and .aar with those built locally. They match. 2. The .module files are not binary equivalent, as the build metdadata and the JSON format is slightly different. The hash doesn't match, but inspecting them, they look semantically equivalent. --- gradle/verification-metadata.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 86ed505ef81b..b8dbd5fe0fe6 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -38,6 +38,7 @@ + @@ -24900,6 +24901,22 @@ + + + + + + + + + + + + + + + + From 6bee3121a06a78b475193247f4443d2a7cf3bb8d Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Wed, 21 Oct 2026 08:42:05 +0200 Subject: [PATCH 11/12] test(scan): Made external/internal distinction unit-testable Assisted-by: ClaudeCode:claude-sonnet-5.5 Signed-off-by: Philipp Hasper --- .../nextcloud/client/scan/ScannedPdfUpload.kt | 30 ++++++++++ .../ui/fragment/OCFileListFragment.java | 35 ++++++------ .../client/scan/ScannedPdfUploadTest.kt | 55 +++++++++++++++++++ 3 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt create mode 100644 app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt diff --git a/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt b/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt new file mode 100644 index 000000000000..25886cb72f17 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.scan + +import android.content.ContentResolver +import android.net.Uri + +sealed interface ScannedPdfUpload { + data class Embedded(val localPath: String, val remoteFilePath: String) : ScannedPdfUpload + + data class External(val uri: Uri, val remoteFolderPath: String) : ScannedPdfUpload + + data object Invalid : ScannedPdfUpload + + companion object { + fun from(pdfUri: Uri, remoteFolderPath: String, remoteFileName: String): ScannedPdfUpload { + if (pdfUri.scheme != ContentResolver.SCHEME_FILE) { + return External(pdfUri, remoteFolderPath) + } + + val localPath = pdfUri.path ?: return Invalid + return Embedded(localPath, remoteFolderPath + remoteFileName) + } + } +} 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 8521221d776e..080751905a86 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 @@ -19,7 +19,6 @@ import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; -import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -50,6 +49,7 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.scan.ScannedPdfUpload; import com.nextcloud.client.systembars.SystemBarBackgroundCallbacks; import com.nextcloud.client.utils.Throttler; import com.nextcloud.common.NextcloudClient; @@ -687,36 +687,33 @@ private void uploadScannedPdf(@NonNull Uri pdfUri) { final OCFile currentDir = getCurrentFile(); final String remotePath = currentDir != null ? currentDir.getRemotePath() : ROOT_PATH; - if (ContentResolver.SCHEME_FILE.equals(pdfUri.getScheme())) { - uploadEmbeddedScannedPdf(user.get(), pdfUri, remotePath); - } else { - uploadExternalScannedPdf(fileActivity, user.get(), pdfUri, remotePath); - } - } + final String fileName = FileOperationsHelper.getTimestampedFileName(".pdf"); + final ScannedPdfUpload upload = ScannedPdfUpload.Companion.from(pdfUri, remotePath, fileName); - private void uploadEmbeddedScannedPdf(@NonNull User user, @NonNull Uri pdfUri, @NonNull String remotePath) { - final String localPath = pdfUri.getPath(); - if (localPath == null) { - Log_OC.e(TAG, "Scanned document has no local path, cant upload: " + pdfUri); - return; + switch (upload) { + case ScannedPdfUpload.Embedded embedded -> uploadEmbeddedScannedPdf(user.get(), embedded); + case ScannedPdfUpload.External external -> uploadExternalScannedPdf(fileActivity, user.get(), external); + case ScannedPdfUpload.Invalid invalid -> + Log_OC.e(TAG, "Scanned document has no local path, cant upload: " + pdfUri); } + } - final String remoteFilePath = remotePath + FileOperationsHelper.getTimestampedFileName(".pdf"); + private void uploadEmbeddedScannedPdf(@NonNull User user, @NonNull ScannedPdfUpload.Embedded upload) { OCUploadLocalPathData data = OCUploadLocalPathData.Companion.forFile( user, - new String[] { localPath }, - new String[] { remoteFilePath }, + new String[] { upload.getLocalPath() }, + new String[] { upload.getRemoteFilePath() }, FileUploadWorker.LOCAL_BEHAVIOUR_MOVE ); FileUploadHelper.Companion.instance().uploadNewFiles(data); } - private void uploadExternalScannedPdf(@NonNull FileActivity fileActivity, @NonNull User user, @NonNull Uri pdfUri, - @NonNull String remotePath) { + private void uploadExternalScannedPdf(@NonNull FileActivity fileActivity, @NonNull User user, + @NonNull ScannedPdfUpload.External external) { new UriUploader( fileActivity, - new ArrayList(List.of(pdfUri)), - remotePath, + new ArrayList(List.of(external.getUri())), + external.getRemoteFolderPath(), user, FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, false, diff --git a/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt b/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt new file mode 100644 index 000000000000..f971012b5d76 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.scan + +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +class ScannedPdfUploadTest { + + private fun uri(scheme: String?, path: String?): Uri = mockk { + every { this@mockk.scheme } returns scheme + every { this@mockk.path } returns path + } + + @Test + fun fileSchemeUriIsTreatedAsEmbeddedScan() { + val result = ScannedPdfUpload.from(uri("file", "/data/files/fairscan/scan.pdf"), "/Documents/", "scan.pdf") + + assertEquals(ScannedPdfUpload.Embedded("/data/files/fairscan/scan.pdf", "/Documents/scan.pdf"), result) + } + + @Test + fun embeddedScanInRootFolderUsesRootPath() { + val result = ScannedPdfUpload.from(uri("file", "/data/scan.pdf"), "/", "20260101_scan.pdf") + + assertEquals(ScannedPdfUpload.Embedded("/data/scan.pdf", "/20260101_scan.pdf"), result) + } + + @Test + fun contentSchemeUriIsTreatedAsExternalScan() { + val uri = uri("content", "/scan.pdf") + + assertEquals(ScannedPdfUpload.External(uri, "/Documents/"), ScannedPdfUpload.from(uri, "/Documents/", "scan.pdf")) + } + + @Test + fun uriWithoutSchemeIsTreatedAsExternalScan() { + val uri = uri(null, null) + + assertEquals(ScannedPdfUpload.External(uri, "/Documents/"), ScannedPdfUpload.from(uri, "/Documents/", "scan.pdf")) + } + + @Test + fun fileSchemeUriWithoutPathIsInvalid() { + assertEquals(ScannedPdfUpload.Invalid, ScannedPdfUpload.from(uri("file", null), "/", "scan.pdf")) + } +} From e19f361ed9959c8b9f7778af606a1b169aee7fbc Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Wed, 21 Oct 2026 22:46:05 +0200 Subject: [PATCH 12/12] test(scan): Added instrumented test Assisted-by: ClaudeCode:claude-sonnet-5.5 Signed-off-by: Philipp Hasper --- .../ui/fragment/OCFileListFragmentScanIT.kt | 115 ++++++++++++++++++ .../nextcloud/client/scan/ScannedPdfUpload.kt | 1 + .../client/scan/ScannedPdfUploadTest.kt | 10 +- 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentScanIT.kt diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentScanIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentScanIT.kt new file mode 100644 index 000000000000..db8fdc830b1b --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentScanIT.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.os.SystemClock +import androidx.test.core.app.launchActivity +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.nextcloud.client.jobs.upload.FileUploadWorker +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +class OCFileListFragmentScanIT : AbstractIT() { + + private lateinit var uploadsStorageManager: UploadsStorageManager + + @Before + fun setUp() { + Intents.init() + uploadsStorageManager = UploadsStorageManager( + UserAccountManagerImpl.fromContext(targetContext), + targetContext.contentResolver + ) + uploadsStorageManager.removeUploads(account.name) + } + + @After + fun tearDown() { + Intents.release() + uploadsStorageManager.removeUploads(account.name) + } + + @Test + fun scanStartsEmbeddedFairScanWithAutoDeletionDisabled() { + scan(Activity.RESULT_CANCELED, null) + + val scanIntent = Intents.getIntents().single { it.action == FAIRSCAN_ACTION } + assertEquals(FAIRSCAN_ACTIVITY, scanIntent.component?.className) + assertEquals(FAIRSCAN_ACTION, scanIntent.action) + assertTrue(scanIntent.getBooleanExtra(FAIRSCAN_EXTRA_DISABLE_AUTO_DELETION, false)) + } + + @Test + fun cancelledScanDoesNotUpload() { + scan(Activity.RESULT_CANCELED, null) + SystemClock.sleep(NO_UPLOAD_GRACE_MILLIS) + + assertEquals(0, uploadsStorageManager.getAllStoredUploads().size) + } + + @Test + fun embeddedScanResultIsQueuedForUploadWithMoveBehaviour() { + val pdf = createFile("scan.pdf", 1) + scan(Activity.RESULT_OK, Intent().putExtra(FAIRSCAN_EXTRA_PDF_PATH, pdf.absolutePath)) + + val upload = waitForUpload() + assertEquals(pdf.absolutePath, upload.localPath) + assertTrue(upload.remotePath.startsWith("/")) + assertTrue(upload.remotePath.endsWith(".pdf")) + assertEquals(FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, upload.localAction) + assertEquals(account.name, upload.accountName) + } + + private fun scan(resultCode: Int, data: Intent?) { + launchActivity().use { scenario -> + intending(hasAction(FAIRSCAN_ACTION)).respondWith(Instrumentation.ActivityResult(resultCode, data)) + scenario.onActivity { activity -> + val fragment = OCFileListFragment() + activity.addFragment(fragment) + activity.supportFragmentManager.executePendingTransactions() + fragment.scanDocUploadFromApp() + } + SystemClock.sleep(RESULT_DELIVERY_MILLIS) + } + } + + private fun waitForUpload(): OCUpload { + val deadline = SystemClock.elapsedRealtime() + WAIT_TIMEOUT_MILLIS + while (SystemClock.elapsedRealtime() < deadline) { + uploadsStorageManager.getAllStoredUploads().firstOrNull()?.let { return it } + SystemClock.sleep(WAIT_POLL_INTERVAL_MILLIS) + } + fail("No upload was queued for the scanned PDF") + error("unreachable") + } + + private companion object { + const val FAIRSCAN_ACTIVITY = "org.fairscan.app.MainActivity" + const val FAIRSCAN_ACTION = "org.fairscan.app.action.SCAN_TO_PDF" + const val FAIRSCAN_EXTRA_DISABLE_AUTO_DELETION = "org.fairscan.app.extra.DISABLE_AUTO_DELETION" + const val FAIRSCAN_EXTRA_PDF_PATH = "org.fairscan.app.extra.PDF_PATH" + const val RESULT_DELIVERY_MILLIS = 1_000L + const val NO_UPLOAD_GRACE_MILLIS = 1_000L + const val WAIT_TIMEOUT_MILLIS = 10_000L + const val WAIT_POLL_INTERVAL_MILLIS = 200L + } +} diff --git a/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt b/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt index 25886cb72f17..e8a9b7fdf6d9 100644 --- a/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt +++ b/app/src/main/java/com/nextcloud/client/scan/ScannedPdfUpload.kt @@ -18,6 +18,7 @@ sealed interface ScannedPdfUpload { data object Invalid : ScannedPdfUpload companion object { + @Suppress("ReturnCount") fun from(pdfUri: Uri, remoteFolderPath: String, remoteFileName: String): ScannedPdfUpload { if (pdfUri.scheme != ContentResolver.SCHEME_FILE) { return External(pdfUri, remoteFolderPath) diff --git a/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt b/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt index f971012b5d76..f23424fd3fdd 100644 --- a/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt +++ b/app/src/test/java/com/nextcloud/client/scan/ScannedPdfUploadTest.kt @@ -38,14 +38,20 @@ class ScannedPdfUploadTest { fun contentSchemeUriIsTreatedAsExternalScan() { val uri = uri("content", "/scan.pdf") - assertEquals(ScannedPdfUpload.External(uri, "/Documents/"), ScannedPdfUpload.from(uri, "/Documents/", "scan.pdf")) + assertEquals( + ScannedPdfUpload.External(uri, "/Documents/"), + ScannedPdfUpload.from(uri, "/Documents/", "scan.pdf") + ) } @Test fun uriWithoutSchemeIsTreatedAsExternalScan() { val uri = uri(null, null) - assertEquals(ScannedPdfUpload.External(uri, "/Documents/"), ScannedPdfUpload.from(uri, "/Documents/", "scan.pdf")) + assertEquals( + ScannedPdfUpload.External(uri, "/Documents/"), + ScannedPdfUpload.from(uri, "/Documents/", "scan.pdf") + ) } @Test