diff --git a/app/src/androidTest/java/com/nmc/android/AlbumsResourceTest.kt b/app/src/androidTest/java/com/nmc/android/AlbumsResourceTest.kt new file mode 100644 index 000000000000..027829da3f2a --- /dev/null +++ b/app/src/androidTest/java/com/nmc/android/AlbumsResourceTest.kt @@ -0,0 +1,286 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nmc.android + +import android.content.Context +import android.content.res.Configuration +import android.util.DisplayMetrics +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.owncloud.android.R +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +/** + * Test class to verify the strings and dimens customized in this branch PR for NMC + */ +@RunWith(AndroidJUnit4::class) +class AlbumsResourceTest { + + private val baseContext = ApplicationProvider.getApplicationContext() + + private val localizedStringMap = mapOf( + R.string.drawer_item_album to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Albums", + Locale.GERMAN to "Alben" + ) + ), R.string.create_album to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Create album", + Locale.GERMAN to "Album erstellen" + ) + ), R.string.create_album_dialog_title to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "New album", + Locale.GERMAN to "Neues Album" + ) + ), R.string.rename_album_dialog_title to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Rename album", + Locale.GERMAN to "Album umbenennen" + ) + ), R.string.rename_dialog_button to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Rename", + Locale.GERMAN to "Speichern" + ) + ), R.string.create_album_dialog_message to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Enter your new Album name", + Locale.GERMAN to "Gib einen Namen für das Album ein" + ) + ), R.string.album_name_empty to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Album name cannot be empty", + Locale.GERMAN to "Der Albumname darf nicht leer sein" + ) + ), R.string.hidden_album_name to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Album name cannot start with invalid char", + Locale.GERMAN to "Der Albumname darf nicht mit einem ungültigen Zeichen beginnen" + ) + ), R.string.add_more to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Add more", + Locale.GERMAN to "Mehr hinzufügen" + ) + ), R.string.album_rename to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Rename Album", + Locale.GERMAN to "Album umbenennen" + ) + ), R.string.album_delete to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Delete Album", + Locale.GERMAN to "Album löschen" + ) + ), R.string.album_delete_failed_message to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Failed to delete few of the files.", + Locale.GERMAN to "Einige Dateien konnten nicht gelöscht werden." + ) + ), R.string.album_already_exists to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Album already exists", + Locale.GERMAN to "Das Album existiert bereits" + ) + ), R.string.album_picker_toolbar_title to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Pick Album", + Locale.GERMAN to "Album auswählen" + ) + ), R.string.media_picker_toolbar_title to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Pick Media Files", + Locale.GERMAN to "Mediendateien auswählen" + ) + ), R.string.empty_albums_title to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Create Albums for your Photos", + Locale.GERMAN to "Erstelle Alben für deine Fotos" + ) + ), R.string.empty_albums_message to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "You can organize all your photos in as many albums as you like. You haven\'t created an album yet.", + Locale.GERMAN to "Sie können all Ihre Fotos in beliebig vielen Alben organisieren. Bisher haben Sie noch kein Album erstellt." + ) + ), R.string.add_to_album to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Add to Album", + Locale.GERMAN to "Zum Album hinzufügen" + ) + ), R.string.album_file_added_message to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "File added successfully", + Locale.GERMAN to "Datei erfolgreich hinzugefügt" + ) + ), R.string.empty_album_detailed_view_title to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "All that\'s missing are your photos", + Locale.GERMAN to "Es fehlen nur noch Ihre Fotos" + ) + ), R.string.empty_album_detailed_view_message to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "You can add as many photos as you like. A photo can also belong to more than one album.", + Locale.GERMAN to "Sie können so viele Fotos hinzufügen, wie Sie möchten. Ein Foto kann auch mehreren Alben zugeordnet werden." + ) + ), + R.string.add_photos to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Add photos", + Locale.GERMAN to "Fotos hinzufügen" + ) + ), R.string.album_items_text to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "%d Items — %s", + Locale.GERMAN to "%d Elemente — %s" + ) + ), R.string.album_unsupported_file to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Unsupported media", + Locale.GERMAN to "Nicht unterstützte Medien" + ) + ), R.string.album_upload_from_camera_roll to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Upload from cameraroll", + Locale.GERMAN to "Dateien hochladen" + ) + ), R.string.album_upload_from_account to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Select images from account", + Locale.GERMAN to "Dateien auswählen" + ) + ), R.string.album_rename_conflict to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "This name is already in use.", + Locale.GERMAN to "Dieser Name wird bereits verwendet." + ) + ), R.string.album_copy_file_conflict to ExpectedLocalizedString( + translations = mapOf( + Locale.ENGLISH to "Already exists.", + Locale.GERMAN to "Existiert bereits." + ) + ), + ) + + @Test + fun verifyLocalizedStrings() { + localizedStringMap.forEach { (stringRes, expected) -> + expected.translations.forEach { (locale, expectedText) -> + + val config = Configuration(baseContext.resources.configuration) + config.setLocale(locale) + + val localizedContext = baseContext.createConfigurationContext(config) + val actualText = localizedContext.getString(stringRes) + + assertEquals( + "Mismatch for ${baseContext.resources.getResourceEntryName(stringRes)} in $locale", + expectedText, + actualText + ) + } + } + } + + data class ExpectedLocalizedString(val translations: Map) + + private val expectedDimenMap = mapOf( + R.dimen.album_list_image_width to ExpectedDimen( + default = 78f, + unit = DimenUnit.DP + ), + R.dimen.album_list_image_height to ExpectedDimen( + default = 56f, + unit = DimenUnit.DP + ), + R.dimen.album_grid_image_height to ExpectedDimen( + default = 140f, + unit = DimenUnit.DP + ), + R.dimen.album_grid_image_corner_radius to ExpectedDimen( + default = 8f, + unit = DimenUnit.DP + ), + R.dimen.album_list_image_corner_radius to ExpectedDimen( + default = 4f, + unit = DimenUnit.DP + ), + R.dimen.album_grid_spacing to ExpectedDimen( + default = 4f, + unit = DimenUnit.DP + ), + R.dimen.album_recycler_view_grid_padding to ExpectedDimen( + default = 8f, + unit = DimenUnit.DP + ), + ) + + @Test + fun validateDefaultDimens() { + validateDimens( + configModifier = { it }, // no change → default values + ) { it.default to it.unit } + } + + @Test + fun validate_sw600dp_Dimens() { + validateDimens(configModifier = { config -> + config.smallestScreenWidthDp = 600 + config + }) { it.alt to it.unit } + } + + private fun validateDimens( + configModifier: (Configuration) -> Configuration, + selector: (ExpectedDimen) -> Pair + ) { + val baseConfig = Configuration(baseContext.resources.configuration) + val testConfig = configModifier(baseConfig) + val testContext = baseContext.createConfigurationContext(testConfig) + val dm = testContext.resources.displayMetrics + val config = testContext.resources.configuration + expectedDimenMap.forEach { (resId, entry) -> + val (value, unit) = selector(entry) + val actualPx = testContext.resources.getDimension(resId) + value?.let { + val expectedPx = convertToPx(value, unit, dm, config) + assertEquals( + "Mismatch for ${testContext.resources.getResourceEntryName(resId)} ($unit)", + expectedPx, + actualPx, + 0.01f + ) + } + } + } + + private fun convertToPx( + value: Float, + unit: DimenUnit, + dm: DisplayMetrics, + config: Configuration + ): Float { + return when (unit) { + DimenUnit.DP -> value * dm.density + DimenUnit.SP -> value * dm.density * config.fontScale + DimenUnit.PX -> value + } + } + + data class ExpectedDimen( + val default: Float, + val alt: Float? = null, + val unit: DimenUnit, + ) + + enum class DimenUnit { DP, SP, PX } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 940739adb944..01cbc462a8ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -619,6 +619,9 @@ android:launchMode="singleTop" android:theme="@style/Theme.ownCloud.Dialog.NoTitle" android:windowSoftInputMode="adjustResize" /> + createFilesExportWork(context, workerParameters) FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters) FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters) + AlbumFileUploadWorker::class -> createAlbumsFilesUploadWorker(context, workerParameters) GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters) HealthStatusWork::class -> createHealthStatusWork(context, workerParameters) TestJob::class -> createTestJob(context, workerParameters) @@ -250,6 +252,20 @@ class BackgroundJobFactory @Inject constructor( params ) + private fun createAlbumsFilesUploadWorker(context: Context, params: WorkerParameters): AlbumFileUploadWorker = + AlbumFileUploadWorker( + uploadsStorageManager, + connectivityService, + powerManagementService, + accountManager, + viewThemeUtils.get(), + localBroadcastManager.get(), + backgroundJobManager.get(), + preferences, + context, + params + ) + private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork = GeneratePdfFromImagesWork( appContext = context, 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 219de803ecda..4dc573d110f4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -138,6 +138,12 @@ interface BackgroundJobManager { fun startNotificationJob(subject: String, signature: String) fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) + fun startAlbumFilesUploadJob( + user: User, + uploadIds: LongArray, + albumName: String, + showSameFileAlreadyExistsNotification: Boolean + ) fun getFileUploads(user: User): LiveData> fun cancelFilesUploadJob(user: User) fun isStartFileUploadJobScheduled(accountName: String): Boolean 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 ed9aaefa6eff..8f37b8978428 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -32,6 +32,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker import com.nextcloud.client.jobs.metadata.MetadataWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.upload.AlbumFileUploadWorker import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.preferences.AppPreferences @@ -86,6 +87,7 @@ internal class BackgroundJobManagerImpl( const val JOB_NOTIFICATION = "notification" const val JOB_ACCOUNT_REMOVAL = "account_removal" const val JOB_FILES_UPLOAD = "files_upload" + const val ALBUM_JOB_FILES_UPLOAD = "album_files_upload" const val JOB_FOLDER_DOWNLOAD = "folder_download" const val JOB_FILES_DOWNLOAD = "files_download" const val JOB_PDF_GENERATION = "pdf_generation" @@ -639,6 +641,7 @@ internal class BackgroundJobManagerImpl( } private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName + private fun startAlbumsFileUploadJobTag(accountName: String): String = ALBUM_JOB_FILES_UPLOAD + accountName override fun isStartFileUploadJobScheduled(accountName: String): Boolean = workManager.isWorkScheduled(startFileUploadJobTag(accountName)) @@ -703,6 +706,68 @@ internal class BackgroundJobManagerImpl( } } + /** + * This method supports uploading and copying selected files to Album + * + * @param user The user for whom the upload job is being created. + * @param uploadIds Array of upload IDs to be processed. These IDs originate from multiple sources + * and cannot be determined directly from the account name or a single function + * within the worker. + * @param albumName Album on which selected files should be copy after upload + */ + override fun startAlbumFilesUploadJob( + user: User, + uploadIds: LongArray, + albumName: String, + showSameFileAlreadyExistsNotification: Boolean + ) { + defaultDispatcherScope.launch { + val batchSize = FileUploadHelper.MAX_FILE_COUNT + val batches = uploadIds.toList().chunked(batchSize) + val tag = startAlbumsFileUploadJobTag(user.accountName) + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val dataBuilder = Data.Builder() + .putBoolean( + FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, + showSameFileAlreadyExistsNotification + ) + .putString(FileUploadWorker.ACCOUNT, user.accountName) + .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size) + .putString(AlbumFileUploadWorker.ALBUM_NAME, albumName) + + val workRequests = batches.mapIndexed { index, batch -> + dataBuilder + .putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray()) + .putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index) + + oneTimeRequestBuilder(AlbumFileUploadWorker::class, ALBUM_JOB_FILES_UPLOAD, user) + .addTag(tag) + .setInputData(dataBuilder.build()) + .setConstraints(constraints) + .build() + } + + // Chain the work requests sequentially + if (workRequests.isNotEmpty()) { + var workChain = workManager.beginUniqueWork( + tag, + ExistingWorkPolicy.APPEND_OR_REPLACE, + workRequests.first() + ) + + workRequests.drop(1).forEach { request -> + workChain = workChain.then(request) + } + + workChain.enqueue() + } + } + } + private fun startFileDownloadJobTag(user: User, fileId: Long): String = JOB_FOLDER_DOWNLOAD + user.accountName + fileId diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt new file mode 100644 index 000000000000..cc8333d6b38f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt @@ -0,0 +1,429 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.BackgroundJobManagerImpl +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.ACCOUNT +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.CURRENT_BATCH_INDEX +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.TOTAL_UPLOAD_SIZE +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.UPLOAD_IDS +import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.model.WorkerState +import com.nextcloud.model.WorkerStateObserver +import com.nextcloud.utils.ForegroundServiceHelper +import com.nextcloud.utils.extensions.getPercent +import com.nextcloud.utils.extensions.updateStatus +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.albums.CopyFileToAlbumOperation +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.random.Random + +/** + * this worker is a replica of FileUploadWorker + * this worker will take care of upload and then copying the uploaded files to selected Album + */ +@Suppress("LongParameterList", "TooGenericExceptionCaught") +class AlbumFileUploadWorker( + val uploadsStorageManager: UploadsStorageManager, + val connectivityService: ConnectivityService, + val powerManagementService: PowerManagementService, + val userAccountManager: UserAccountManager, + val viewThemeUtils: ViewThemeUtils, + val localBroadcastManager: LocalBroadcastManager, + private val backgroundJobManager: BackgroundJobManager, + val preferences: AppPreferences, + val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params), + OnDatatransferProgressListener { + + companion object { + val TAG: String = AlbumFileUploadWorker::class.java.simpleName + + var currentUploadFileOperation: UploadFileOperation? = null + + private const val BATCH_SIZE = 100 + + const val ALBUM_NAME = "album_name" + } + + private var lastPercent = 0 + private val notificationId = Random.nextInt() + private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) + private val intents = FileUploaderIntents(context) + private val fileUploaderDelegate = FileUploaderDelegate() + + override suspend fun doWork(): Result = try { + Log_OC.d(TAG, "AlbumFileUploadWorker started") + val workerName = BackgroundJobManagerImpl.formatClassTag(this::class) + backgroundJobManager.logStartOfWorker(workerName) + + trySetForeground() + + val result = uploadFiles() + backgroundJobManager.logEndOfWorker(workerName, result) + notificationManager.dismissNotification() + if (result == Result.success()) { + setIdleWorkerState() + } + result + } catch (t: Throwable) { + Log_OC.e(TAG, "Error caught at AlbumFileUploadWorker $t") + cleanup() + Result.failure() + } + + private suspend fun trySetForeground() { + try { + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + updateForegroundInfo(notification) + } catch (e: Exception) { + // Continue without foreground service - uploads will still work + Log_OC.w(TAG, "Could not set foreground service: ${e.message}") + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + + return ForegroundServiceHelper.createWorkerForegroundInfo( + notificationId, + notification, + ForegroundServiceType.DataSync + ) + } + + private suspend fun updateForegroundInfo(notification: Notification) { + val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + notificationId, + notification, + ForegroundServiceType.DataSync + ) + setForeground(foregroundInfo) + } + + private fun createNotification(title: String): Notification = + NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setContentTitle(title) + .setSmallIcon(R.drawable.uploads) + .setOngoing(true) + .setSound(null) + .setVibrate(null) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .build() + + private fun cleanup() { + Log_OC.e(TAG, "AlbumFileUploadWorker stopped") + + setIdleWorkerState() + currentUploadFileOperation?.cancel(null) + notificationManager.dismissNotification() + } + + private fun setWorkerState(user: User?) { + WorkerStateObserver.send(WorkerState.FileUploadStarted(user)) + } + + private fun setIdleWorkerState() { + WorkerStateObserver.send(WorkerState.FileUploadCompleted(currentUploadFileOperation?.file)) + } + + @Suppress("ReturnCount", "LongMethod", "DEPRECATION") + private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) { + val accountName = inputData.getString(ACCOUNT) + if (accountName == null) { + Log_OC.e(TAG, "accountName is null") + return@withContext Result.failure() + } + + val uploadIds = inputData.getLongArray(UPLOAD_IDS) + if (uploadIds == null) { + Log_OC.e(TAG, "uploadIds is null") + return@withContext Result.failure() + } + + val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1) + if (currentBatchIndex == -1) { + Log_OC.e(TAG, "currentBatchIndex is -1, cancelling") + return@withContext Result.failure() + } + + val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1) + if (totalUploadSize == -1) { + Log_OC.e(TAG, "totalUploadSize is -1, cancelling") + return@withContext Result.failure() + } + + // since worker's policy is append or replace and account name comes from there no need check in the loop + val optionalUser = userAccountManager.getUser(accountName) + if (!optionalUser.isPresent) { + Log_OC.e(TAG, "User not found for account: $accountName") + return@withContext Result.failure() + } + + val albumName = inputData.getString(ALBUM_NAME) + if (albumName == null) { + Log_OC.e(TAG, "album name is null") + return@withContext Result.failure() + } + + val user = optionalUser.get() + val previouslyUploadedFileSize = currentBatchIndex * FileUploadHelper.MAX_FILE_COUNT + val uploads = uploadsStorageManager.getUploadsByIds(uploadIds, accountName) + val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) + + for ((index, upload) in uploads.withIndex()) { + ensureActive() + + if (preferences.isGlobalUploadPaused) { + Log_OC.d(TAG, "Upload is paused, skip uploading files!") + notificationManager.notifyPaused( + intents.openUploadListIntent(null) + ) + return@withContext Result.success() + } + + if (canExitEarly()) { + notificationManager.showConnectionErrorNotification() + return@withContext Result.failure() + } + + setWorkerState(user) + val operation = createUploadFileOperation(upload, user) + currentUploadFileOperation = operation + + val currentIndex = (index + 1) + val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) + notificationManager.prepareForStart( + operation, + startIntent = intents.openUploadListIntent(operation), + currentUploadIndex = currentUploadIndex, + totalUploadSize = totalUploadSize + ) + + val result = withContext(Dispatchers.IO) { + upload(operation, albumName, user, client) + } + val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) + uploadsStorageManager.updateStatus(entity, result.isSuccess) + currentUploadFileOperation = null + + if (result.code == ResultCode.QUOTA_EXCEEDED) { + Log_OC.w(TAG, "Quota exceeded, stopping uploads") + notificationManager.showQuotaExceedNotification(operation) + break + } + + sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) + } + + return@withContext Result.success() + } + + private fun sendUploadFinishEvent( + totalUploadSize: Int, + currentUploadIndex: Int, + operation: UploadFileOperation, + result: RemoteOperationResult<*> + ) { + val shouldBroadcast = + (totalUploadSize > BATCH_SIZE && currentUploadIndex > 0) && currentUploadIndex % BATCH_SIZE == 0 + + if (shouldBroadcast) { + // delay broadcast + fileUploaderDelegate.sendBroadcastUploadFinished( + operation, + result, + operation.oldFile?.storagePath, + context, + localBroadcastManager + ) + } + } + + private fun canExitEarly(): Boolean { + val result = !connectivityService.isConnected || + connectivityService.isInternetWalled || + isStopped + + if (result) { + Log_OC.d(TAG, "No internet connection, stopping worker.") + } else { + notificationManager.dismissErrorNotification() + } + + return result + } + + private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + user, + null, + upload, + upload.nameCollisionPolicy, + upload.localAction, + context, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + true, + FileDataStorageManager(user, context.contentResolver) + ).apply { + addDataTransferProgressListener(this@AlbumFileUploadWorker) + } + + @Suppress("TooGenericExceptionCaught", "DEPRECATION") + private suspend fun upload( + operation: UploadFileOperation, + albumName: String, + user: User, + client: OwnCloudClient + ): RemoteOperationResult = withContext(Dispatchers.IO) { + lateinit var result: RemoteOperationResult + + try { + val storageManager = operation.storageManager + result = operation.execute(client) + val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user) + val file = File(operation.originalStoragePath) + val remoteId: String? = operation.file.remoteId + task.execute(ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, remoteId)) + val copyAlbumFileOperation = + CopyFileToAlbumOperation(operation.remotePath, albumName, storageManager) + val copyResult = copyAlbumFileOperation.execute(client) + if (copyResult.isSuccess) { + Log_OC.e(TAG, "Successful copied file to Album: $albumName") + } else { + Log_OC.e(TAG, "Failed to copy file to Album: $albumName due to ${copyResult.logMessage}") + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error uploading", e) + result = RemoteOperationResult(e) + } finally { + if (!isStopped) { + uploadsStorageManager.updateDatabaseUploadResult(result, operation) + // NMC: resolving file conflict will trigger normal file upload and shows two upload process + // one for normal and one for Album upload + // as customizing conflict can break normal upload + // so we are removing the upload if it's a conflict + // Note: this is fallback logic because default policy while uploading is RENAME + // if in some case code reach here it will remove the upload + // so we are checking it first and removing the upload + if (result.code == ResultCode.SYNC_CONFLICT) { + uploadsStorageManager.removeUpload( + operation.user.accountName, + operation.remotePath + ) + } else { + UploadErrorNotificationManager.handleResult( + context, + notificationManager, + operation, + result, + showSameFileAlreadyExistsNotification = { + withContext(Dispatchers.Main) { + val showSameFileAlreadyExistsNotification = + inputData.getBoolean(SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false) + if (showSameFileAlreadyExistsNotification) { + notificationManager.showSameFileAlreadyExistsNotification(operation.fileName) + } + } + } + ) + } + } + } + + return@withContext result + } + + @Suppress("MagicNumber") + private val minProgressUpdateInterval = 750 + private var lastUpdateTime = 0L + + /** + * Receives from [com.owncloud.android.operations.UploadFileOperation.normalUpload] + */ + @Suppress("MagicNumber") + override fun onTransferProgress( + progressRate: Long, + totalTransferredSoFar: Long, + totalToTransfer: Long, + fileAbsoluteName: String + ) { + val percent = getPercent(totalTransferredSoFar, totalToTransfer) + val currentTime = System.currentTimeMillis() + + if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { + notificationManager.run { + val accountName = currentUploadFileOperation?.user?.accountName + val remotePath = currentUploadFileOperation?.remotePath + + updateUploadProgress(percent, currentUploadFileOperation) + + if (accountName != null && remotePath != null) { + val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath) + val boundListener = FileUploadHelper.mBoundListeners[key] + val filename = currentUploadFileOperation?.fileName ?: "" + + boundListener?.onTransferProgress( + progressRate, + totalTransferredSoFar, + totalToTransfer, + filename + ) + } + + dismissOldErrorNotification(currentUploadFileOperation) + } + lastUpdateTime = currentTime + } + + lastPercent = percent + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 566d80415c08..87cdf0599234 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -249,6 +249,45 @@ class FileUploadHelper { backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification) } + @JvmOverloads + @Suppress("LongParameterList") + fun uploadAndCopyNewFilesForAlbum( + user: User, + localPaths: Array, + remotePaths: Array, + albumName: String, + localBehavior: Int, + createRemoteFolder: Boolean, + createdBy: Int, + requiresWifi: Boolean, + requiresCharging: Boolean, + nameCollisionPolicy: NameCollisionPolicy, + showSameFileAlreadyExistsNotification: Boolean = true + ) { + + val uploads = localPaths.mapIndexed { index, localPath -> + val result = OCUpload(localPath, remotePaths[index], user.accountName).apply { + this.nameCollisionPolicy = nameCollisionPolicy + isUseWifiOnly = requiresWifi + isWhileChargingOnly = requiresCharging + uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS + this.createdBy = createdBy + isCreateRemoteFolder = createRemoteFolder + localAction = localBehavior + } + + val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity()) + result.uploadId = id + result + } + backgroundJobManager.startAlbumFilesUploadJob( + user, + uploads.getUploadIds(), + albumName, + showSameFileAlreadyExistsNotification + ) + } + fun removeFileUpload(remotePath: String, accountName: String) { uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(remotePath, accountName) } @@ -360,7 +399,7 @@ class FileUploadHelper { @Suppress("ReturnCount") fun isUploadingNow(upload: OCUpload?): Boolean { - val currentUploadFileOperation = currentUploadFileOperation + val currentUploadFileOperation = currentUploadFileOperation ?: AlbumFileUploadWorker.currentUploadFileOperation if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt new file mode 100644 index 000000000000..9d31afca3def --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.albumItemActions + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import com.owncloud.android.R + +enum class AlbumItemAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) { + RENAME_ALBUM(R.id.action_rename_file, R.string.album_rename, R.drawable.ic_edit), + DELETE_ALBUM(R.id.action_delete, R.string.album_delete, R.drawable.ic_delete); + + companion object { + /** + * All file actions, in the order they should be displayed + */ + @JvmField + val SORTED_VALUES = listOf( + RENAME_ALBUM, + DELETE_ALBUM + ) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt new file mode 100644 index 000000000000..c697d5229b56 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.albumItemActions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IdRes +import androidx.core.os.bundleOf +import androidx.core.view.isEmpty +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.nextcloud.client.di.Injectable +import com.owncloud.android.databinding.FileActionsBottomSheetBinding +import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class AlbumItemActionsBottomSheet : BottomSheetDialogFragment(), Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private var _binding: FileActionsBottomSheetBinding? = null + val binding + get() = _binding!! + + fun interface ResultListener { + fun onResult(@IdRes actionId: Int) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FileActionsBottomSheetBinding.inflate(inflater, container, false) + + val bottomSheetDialog = dialog as BottomSheetDialog + bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + bottomSheetDialog.behavior.skipCollapsed = true + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.bottomSheetHeader.visibility = View.GONE + binding.bottomSheetLoading.visibility = View.GONE + displayActions() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun setResultListener( + fragmentManager: FragmentManager, + lifecycleOwner: LifecycleOwner, + listener: ResultListener + ): AlbumItemActionsBottomSheet { + fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result -> + @IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1) + if (actionId != -1) { + listener.onResult(actionId) + } + } + return this + } + + private fun displayActions() { + if (binding.fileActionsList.isEmpty()) { + AlbumItemAction.SORTED_VALUES.forEach { action -> + val view = inflateActionView(action) + binding.fileActionsList.addView(view) + } + } + } + + private fun inflateActionView(action: AlbumItemAction): View { + val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + root.setOnClickListener { + dispatchActionClick(action.id) + } + text.setText(action.title) + if (action.icon != null) { + icon.setImageResource(action.icon) + } + } + return itemBinding.root + } + + private fun dispatchActionClick(id: Int?) { + if (id != null) { + setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id)) + parentFragmentManager.clearFragmentResultListener(REQUEST_KEY) + dismiss() + } + } + + companion object { + private const val REQUEST_KEY = "REQUEST_KEY_ACTION" + private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID" + + @JvmStatic + fun newInstance(): AlbumItemActionsBottomSheet { + return AlbumItemActionsBottomSheet() + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java index 1259b706a2b1..ffc68007e616 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java +++ b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java @@ -12,5 +12,5 @@ * Type for virtual folders */ public enum VirtualFolderType { - FAVORITE, GALLERY, NONE + FAVORITE, GALLERY, ALBUM, NONE } diff --git a/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt new file mode 100644 index 000000000000..875f547056cb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations + +import android.content.Context +import com.nextcloud.client.account.User +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.SearchRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.common.SyncOperation +import com.owncloud.android.utils.FileStorageUtils + +/** + * fetch OCFile meta data if not present in local db + * + * @see com.owncloud.android.ui.asynctasks.FetchRemoteFileTask reference for this operation + * + * @param ocFile file for which metadata has to retrieve + * @param removeFileFromDb if you want to remove ocFile from local db to avoid duplicate entries for same fileId + */ +class FetchRemoteFileOperation( + private val context: Context, + private val user: User, + private val ocFile: OCFile, + private val removeFileFromDb: Boolean = false, + storageManager: FileDataStorageManager, +) : SyncOperation(storageManager) { + + @Deprecated("Deprecated in Java") + override fun run(client: OwnCloudClient?): RemoteOperationResult<*>? { + val searchRemoteOperation = SearchRemoteOperation( + ocFile.localId.toString(), + SearchRemoteOperation.SearchType.FILE_ID_SEARCH, + false, + storageManager.getCapability(user) + ) + val remoteOperationResult: RemoteOperationResult> = + searchRemoteOperation.execute(user, context) + + if (remoteOperationResult.isSuccess && remoteOperationResult.resultData != null) { + if (remoteOperationResult.resultData.isEmpty()) { + Log_OC.e(TAG, "No remote file found with id: ${ocFile.localId}.") + return remoteOperationResult + } + val remotePath = (remoteOperationResult.resultData[0]).remotePath + + val operation = ReadFileRemoteOperation(remotePath) + val result = operation.execute(user, context) + + if (!result.isSuccess) { + val exception = result.exception + val message = + "Fetching file " + remotePath + " fails with: " + result.getLogMessage(MainApp.getAppContext()) + Log_OC.e(TAG, exception?.message ?: message) + + return result + } + + val remoteFile = result.data[0] as RemoteFile + + // remove file from local db + if (removeFileFromDb) { + storageManager.removeFile(ocFile, true, true) + } + + var ocFile = FileStorageUtils.fillOCFile(remoteFile) + FileStorageUtils.searchForLocalFileInDefaultPath(ocFile, user.accountName) + ocFile = storageManager.saveFileWithParent(ocFile, context) + + // also sync folder content + val toSync: OCFile? = if (ocFile?.isFolder == true) { + ocFile + } else { + ocFile?.parentId?.let { storageManager.getFileById(it) } + } + + val currentSyncTime = System.currentTimeMillis() + val refreshFolderOperation: RemoteOperation = RefreshFolderOperation( + toSync, + currentSyncTime, + true, + true, + storageManager, + user, + context + ) + val refreshOperationResult = refreshFolderOperation.execute(user, context) + + // set the fetched ocFile to resultData to be handled at ui end + refreshOperationResult.resultData = ocFile + + return refreshOperationResult + } + return remoteOperationResult + } + + companion object { + private val TAG = FetchRemoteFileOperation::class.java.simpleName + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt new file mode 100644 index 000000000000..5c1d5b4f8fe7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.albums + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.common.SyncOperation + +/** + * Constructor + * + * @param srcPath Remote path of the [OCFile] to move. + * @param targetParentPath Path to the folder where the file will be copied into. + */ +class CopyFileToAlbumOperation( + private val srcPath: String, + private var targetParentPath: String, + storageManager: FileDataStorageManager +) : + SyncOperation(storageManager) { + init { + if (!targetParentPath.endsWith(OCFile.PATH_SEPARATOR)) { + this.targetParentPath += OCFile.PATH_SEPARATOR + } + } + + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Deprecated("Deprecated in Java") + @Suppress("NestedBlockDepth") + override fun run(client: OwnCloudClient): RemoteOperationResult { + /** 1. check copy validity */ + val result: RemoteOperationResult + + if (targetParentPath.startsWith(srcPath)) { + result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT) + } else { + val file = storageManager.getFileByPath(srcPath) + if (file == null) { + result = RemoteOperationResult(ResultCode.FILE_NOT_FOUND) + } else { + /** 2. remote copy */ + var targetPath = "$targetParentPath${file.fileName}" + if (file.isFolder) { + targetPath += OCFile.PATH_SEPARATOR + } + + // auto rename, to allow copy + if (targetPath == srcPath) { + if (file.isFolder) { + targetPath = "$targetParentPath${file.fileName}" + } + targetPath = UploadFileOperation.getNewAvailableRemotePath(client, targetPath, null, false) + + if (file.isFolder) { + targetPath += OCFile.PATH_SEPARATOR + } + } + + result = CopyFileToAlbumRemoteOperation(srcPath, targetPath).execute(client) + } + } + return result + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt new file mode 100644 index 000000000000..f2fd99d6945b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt @@ -0,0 +1,157 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2012-2014 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 Jorge Antonio Diaz-Benito Soriano + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.albums + +import android.util.Log +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.DavException +import org.apache.jackrabbit.webdav.Status +import org.apache.jackrabbit.webdav.client.methods.CopyMethod +import java.io.IOException + +/** + * Remote operation moving a remote file or folder in the ownCloud server to a different folder + * in the same account. + * + * + * Allows renaming the moving file/folder at the same time. + */ +class CopyFileToAlbumRemoteOperation @JvmOverloads constructor( + private val mSrcRemotePath: String, + private val mTargetRemotePath: String, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut +) : + RemoteOperation() { + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult { + /** check parameters */ + + var result: RemoteOperationResult + if (mTargetRemotePath == mSrcRemotePath) { + // nothing to do! + result = RemoteOperationResult(ResultCode.OK) + } else if (mTargetRemotePath.startsWith(mSrcRemotePath)) { + result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT) + } else { + /** perform remote operation */ + var copyMethod: CopyMethod? = null + try { + copyMethod = CopyMethod( + client.getFilesDavUri(this.mSrcRemotePath), + "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${ + WebdavUtils.encodePath( + mTargetRemotePath + ) + }", + false + ) + val status = client.executeMethod( + copyMethod, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + + /** process response */ + result = when (status) { + HttpStatus.SC_MULTI_STATUS -> processPartialError(copyMethod) + HttpStatus.SC_PRECONDITION_FAILED -> { + client.exhaustResponse(copyMethod.responseBodyAsStream) + RemoteOperationResult(ResultCode.INVALID_OVERWRITE) + } + + else -> { + client.exhaustResponse(copyMethod.responseBodyAsStream) + RemoteOperationResult(isSuccess(status), copyMethod) + } + } + + Log.i( + TAG, + "Copy $mSrcRemotePath to $mTargetRemotePath : ${result.logMessage}" + ) + } catch (e: Exception) { + result = RemoteOperationResult(e) + Log.e( + TAG, + "Copy $mSrcRemotePath to $mTargetRemotePath : ${result.logMessage}", e + ) + } finally { + copyMethod?.releaseConnection() + } + } + + return result + } + + /** + * Analyzes a multistatus response from the OC server to generate an appropriate result. + * + * + * In WebDAV, a COPY request on collections (folders) can be PARTIALLY successful: some + * children are copied, some other aren't. + * + * + * According to the WebDAV specification, a multistatus response SHOULD NOT include partial + * successes (201, 204) nor for descendants of already failed children (424) in the response + * entity. But SHOULD NOT != MUST NOT, so take carefully. + * + * @param copyMethod Copy operation just finished with a multistatus response + * @return A result for the [CopyFileToAlbumRemoteOperation] caller + * @throws java.io.IOException If the response body could not be parsed + * @throws org.apache.jackrabbit.webdav.DavException If the status code is other than MultiStatus or if obtaining + * the response XML document fails + */ + @Throws(IOException::class, DavException::class) + private fun processPartialError(copyMethod: CopyMethod): RemoteOperationResult { + // Adding a list of failed descendants to the result could be interesting; or maybe not. + // For the moment, let's take the easy way. + /** check that some error really occurred */ + + val responses = copyMethod.responseBodyAsMultiStatus.responses + var status: Array? + var failFound = false + var i = 0 + while (i < responses.size && !failFound) { + status = responses[i].status + failFound = (!status.isNullOrEmpty() && status[0].statusCode > FAILED_STATUS_CODE + ) + i++ + } + val result: RemoteOperationResult = if (failFound) { + RemoteOperationResult(ResultCode.PARTIAL_COPY_DONE) + } else { + RemoteOperationResult(true, copyMethod) + } + + return result + } + + private fun isSuccess(status: Int): Boolean { + return status == HttpStatus.SC_CREATED || status == HttpStatus.SC_NO_CONTENT + } + + companion object { + private val TAG: String = CopyFileToAlbumRemoteOperation::class.java.simpleName + private const val FAILED_STATUS_CODE = 299 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt new file mode 100644 index 000000000000..34c4742652d8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.albums + +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.client.methods.MkColMethod + +class CreateNewAlbumRemoteOperation + @JvmOverloads + constructor( + val newAlbumName: String, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut + ) : RemoteOperation() { + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult { + var mkCol: MkColMethod? = null + var result: RemoteOperationResult + try { + mkCol = + MkColMethod( + "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${ + WebdavUtils.encodePath( + newAlbumName + ) + }" + ) + client.executeMethod( + mkCol, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + if (HttpStatus.SC_METHOD_NOT_ALLOWED == mkCol.statusCode) { + result = + RemoteOperationResult(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS) + } else { + result = RemoteOperationResult(mkCol.succeeded(), mkCol) + result.resultData = null + } + + Log_OC.d(TAG, "Create album $newAlbumName : ${result.logMessage}") + client.exhaustResponse(mkCol.responseBodyAsStream) + } catch (e: Exception) { + result = RemoteOperationResult(e) + Log_OC.e(TAG, "Create album $newAlbumName : ${result.logMessage}", e) + } finally { + mkCol?.releaseConnection() + } + + return result + } + + companion object { + private val TAG: String = CreateNewAlbumRemoteOperation::class.java.simpleName + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt b/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt new file mode 100644 index 000000000000..39ebdd68d220 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.albums + +import com.owncloud.android.lib.common.network.WebdavEntry +import com.owncloud.android.utils.DisplayUtils +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.MultiStatusResponse +import org.apache.jackrabbit.webdav.property.DavPropertyName +import org.apache.jackrabbit.webdav.property.DavPropertySet +import org.apache.jackrabbit.webdav.xml.Namespace +import org.json.JSONException +import org.json.JSONObject +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +class PhotoAlbumEntry( + response: MultiStatusResponse +) { + val href: String + val lastPhoto: Long + val nbItems: Int + val location: String? + private val dateRange: String? + + companion object { + private const val DATE_PATTERN = "MMM yyyy" + private const val MILLIS = 1000L + private const val PROPERTY_LAST_PHOTO = "last-photo" + private const val PROPERTY_NB_ITEMS = "nbItems" + private const val PROPERTY_LOCATION = "location" + private const val PROPERTY_DATE_RANGE = "dateRange" + private const val PROPERTY_COLLABORATORS = "collaborators" + } + + init { + + href = response.href + + val properties = response.getProperties(HttpStatus.SC_OK) + + this.lastPhoto = parseLong(parseString(properties, PROPERTY_LAST_PHOTO)) + this.nbItems = parseInt(parseString(properties, PROPERTY_NB_ITEMS)) + this.location = parseString(properties, PROPERTY_LOCATION) + this.dateRange = parseString(properties, PROPERTY_DATE_RANGE) + } + + private fun parseString( + props: DavPropertySet, + name: String + ): String? { + val propName = DavPropertyName.create(name, Namespace.getNamespace("nc", WebdavEntry.NAMESPACE_NC)) + val prop = props[propName] + return if (prop != null && prop.value != null) prop.value.toString() else null + } + + private fun parseInt(value: String?): Int = + try { + value?.toInt() ?: 0 + } catch (_: NumberFormatException) { + 0 + } + + private fun parseLong(value: String?): Long = + try { + value?.toLong() ?: 0L + } catch (_: NumberFormatException) { + 0L + } + + val albumName: String + get() { + // NMC-4610 fix + // use decoder to show correct path + return URLDecoder.decode( + href + .removeSuffix("/") + .substringAfterLast("/") + .takeIf { it.isNotEmpty() } ?: "", StandardCharsets.UTF_8.name()) + } + + val createdDate: String + get() { + val defaultDate = DisplayUtils.getDateByPattern(System.currentTimeMillis(), DATE_PATTERN) + + return try { + val obj = JSONObject(dateRange ?: return defaultDate) + val startTimestamp = obj.optLong("start", 0) + if (startTimestamp > 0) { + DisplayUtils.getDateByPattern(startTimestamp * MILLIS, DATE_PATTERN) + } else { + defaultDate + } + } catch (e: JSONException) { + e.printStackTrace() + defaultDate + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt new file mode 100644 index 000000000000..8daf196c3767 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt @@ -0,0 +1,141 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.albums + +import androidx.core.net.toUri +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavEntry +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.utils.FileStorageUtils +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.DavConstants +import org.apache.jackrabbit.webdav.MultiStatus +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod + +class ReadAlbumItemsRemoteOperation +@JvmOverloads +constructor( + private val mRemotePath: String, + private val storageManager: FileDataStorageManager?, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut +) : RemoteOperation>() { + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult> { + var result: RemoteOperationResult>? = null + var query: PropFindMethod? = null + val url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${ + WebdavUtils.encodePath( + mRemotePath + ) + }" + try { + // remote request + query = + PropFindMethod( + url, + WebdavUtils.getAllPropSet(), // PropFind Properties + DavConstants.DEPTH_1 + ) + val status = + client.executeMethod( + query, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + + // check and process response + val isSuccess = (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK) + + result = + if (isSuccess) { + // get data from remote folder + val dataInServer = query.responseBodyAsMultiStatus + val mFolderAndFiles = readAlbumData(dataInServer, client, storageManager) + + // Result of the operation + RemoteOperationResult>(true, query).apply { + // Add data to the result + resultData = mFolderAndFiles + } + } else { + // synchronization failed + client.exhaustResponse(query.responseBodyAsStream) + RemoteOperationResult(false, query) + } + } catch (e: Exception) { + result = RemoteOperationResult(e) + } finally { + query?.releaseConnection() + + result = result ?: RemoteOperationResult>(Exception("unknown error")).also { + Log_OC.e(TAG, "Synchronized $mRemotePath: failed") + } + if (result.isSuccess) { + Log_OC.i(TAG, "Synchronized $mRemotePath : ${result.logMessage}") + } else if (result.isException) { + Log_OC.e(TAG, "Synchronized $mRemotePath : ${result.logMessage}", result.exception) + } else { + Log_OC.e(TAG, "Synchronized $mRemotePath : ${result.logMessage}") + } + } + + return result + } + + companion object { + private val TAG: String = ReadAlbumItemsRemoteOperation::class.java.simpleName + + private fun readAlbumData( + remoteData: MultiStatus, + client: OwnCloudClient, + storageManager: FileDataStorageManager? + ): List { + val baseUrl = "${client.baseUri}/remote.php/dav/photos/${client.userId}" + val encodedPath = baseUrl.toUri().encodedPath ?: return emptyList() + + val files = mutableListOf() + val responses = remoteData.responses + + // reading from 1 as 0th item will be just the root album path + for (i in 1.. + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.albums + +import android.text.TextUtils +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavEntry +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.DavConstants +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod +import org.apache.jackrabbit.webdav.property.DavPropertyName +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet +import org.apache.jackrabbit.webdav.xml.Namespace + +class ReadAlbumsRemoteOperation +@JvmOverloads +constructor( + private val mAlbumRemotePath: String? = null, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut +) : RemoteOperation>() { + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult> { + var propfind: PropFindMethod? = null + var result: RemoteOperationResult> + var url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums" + if (!TextUtils.isEmpty(mAlbumRemotePath)) { + url += WebdavUtils.encodePath(mAlbumRemotePath) + } + try { + propfind = PropFindMethod(url, getAlbumPropSet(), DavConstants.DEPTH_1) + val status = + client.executeMethod( + propfind, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + val isSuccess = status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK + if (isSuccess) { + val albumsList = + propfind.responseBodyAsMultiStatus.responses + .filter { it.status[0].statusCode == HttpStatus.SC_OK } + .map { res -> PhotoAlbumEntry(res) } + result = RemoteOperationResult>(true, propfind) + result.resultData = albumsList + } else { + result = RemoteOperationResult>(false, propfind) + client.exhaustResponse(propfind.responseBodyAsStream) + } + } catch (e: Exception) { + result = RemoteOperationResult>(e) + Log_OC.e(TAG, "Read album failed: ${result.logMessage}", result.exception) + } finally { + propfind?.releaseConnection() + } + + return result + } + + companion object { + private val TAG: String = ReadAlbumsRemoteOperation::class.java.simpleName + private const val PROPERTY_LAST_PHOTO = "last-photo" + private const val PROPERTY_NB_ITEMS = "nbItems" + private const val PROPERTY_LOCATION = "location" + private const val PROPERTY_DATE_RANGE = "dateRange" + private const val PROPERTY_COLLABORATORS = "collaborators" + + private fun getAlbumPropSet(): DavPropertyNameSet { + val propertySet = DavPropertyNameSet() + val ncNamespace: Namespace = Namespace.getNamespace("nc", WebdavEntry.NAMESPACE_NC) + + propertySet.add(DavPropertyName.create(PROPERTY_LAST_PHOTO, ncNamespace)) + propertySet.add(DavPropertyName.create(PROPERTY_NB_ITEMS, ncNamespace)) + propertySet.add(DavPropertyName.create(PROPERTY_LOCATION, ncNamespace)) + propertySet.add(DavPropertyName.create(PROPERTY_DATE_RANGE, ncNamespace)) + propertySet.add(DavPropertyName.create(PROPERTY_COLLABORATORS, ncNamespace)) + + return propertySet + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt new file mode 100644 index 000000000000..529480928572 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.albums + +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.client.methods.DeleteMethod + +class RemoveAlbumFileRemoteOperation + @JvmOverloads + constructor( + private val mRemotePath: String, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut + ) : RemoteOperation() { + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult { + var result: RemoteOperationResult + var delete: DeleteMethod? = null + val webDavUrl = "${client.davUri}/photos/" + val encodedPath = "${client.userId}${WebdavUtils.encodePath(this.mRemotePath)}" + val fullFilePath = "$webDavUrl$encodedPath" + + try { + delete = DeleteMethod(fullFilePath) + val status = + client.executeMethod( + delete, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + delete.responseBodyAsString + result = + RemoteOperationResult( + delete.succeeded() || status == HttpStatus.SC_NOT_FOUND, + delete + ) + Log_OC.i(TAG, "Remove ${this.mRemotePath} : ${result.logMessage}") + } catch (e: Exception) { + result = RemoteOperationResult(e) + Log_OC.e(TAG, "Remove ${this.mRemotePath} : ${result.logMessage}", e) + } finally { + delete?.releaseConnection() + } + + return result + } + + companion object { + private val TAG: String = RemoveFileRemoteOperation::class.java.simpleName + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt new file mode 100644 index 000000000000..17f29035ab3b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.albums + +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.client.methods.DeleteMethod + +class RemoveAlbumRemoteOperation + @JvmOverloads + constructor( + private val albumName: String, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut + ) : RemoteOperation() { + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult { + var result: RemoteOperationResult + var delete: DeleteMethod? = null + + try { + delete = + DeleteMethod( + "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${ + WebdavUtils.encodePath( + albumName + ) + }" + ) + val status = + client.executeMethod( + delete, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + delete.responseBodyAsString + result = + RemoteOperationResult( + delete.succeeded() || status == HttpStatus.SC_NOT_FOUND, + delete + ) + Log_OC.i(TAG, "Remove ${this.albumName} : ${result.logMessage}") + } catch (e: Exception) { + result = RemoteOperationResult(e) + Log_OC.e(TAG, "Remove ${this.albumName} : ${result.logMessage}", e) + } finally { + delete?.releaseConnection() + } + + return result + } + + companion object { + private val TAG: String = RemoveAlbumRemoteOperation::class.java.simpleName + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt new file mode 100644 index 000000000000..523c3cbadc3b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt @@ -0,0 +1,87 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.albums + +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.client.methods.MoveMethod + +class RenameAlbumRemoteOperation + @JvmOverloads + constructor( + private val mOldRemotePath: String, + val newAlbumName: String, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut + ) : RemoteOperation() { + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult? { + var result: RemoteOperationResult? = null + var move: MoveMethod? = null + val url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums" + try { + if (this.newAlbumName != this.mOldRemotePath) { + move = + MoveMethod( + "$url${WebdavUtils.encodePath(mOldRemotePath)}", + "$url${ + WebdavUtils.encodePath( + newAlbumName + ) + }", + false + ) + client.executeMethod( + move, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + result = RemoteOperationResult(move.succeeded(), move) + Log_OC.i( + TAG, + "Rename ${this.mOldRemotePath} to ${this.newAlbumName} : ${result.logMessage}" + ) + // NMC-4786 fix + // album name already exist + if (!result.isSuccess && result.httpCode == HttpStatus.SC_PRECONDITION_FAILED) { + result = RemoteOperationResult(RemoteOperationResult.ResultCode.INVALID_OVERWRITE) + } + client.exhaustResponse(move.responseBodyAsStream) + } + } catch (e: Exception) { + result = RemoteOperationResult(e) + Log_OC.e( + TAG, + "Rename ${this.mOldRemotePath} to ${this.newAlbumName} : ${result.logMessage}", + e + ) + } finally { + move?.releaseConnection() + } + + return result + } + + companion object { + private val TAG: String = RenameAlbumRemoteOperation::class.java.simpleName + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt new file mode 100644 index 000000000000..d2f23d5a12a3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.albums + +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.network.WebdavEntry +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation +import org.apache.commons.httpclient.HttpStatus +import org.apache.jackrabbit.webdav.client.methods.PropPatchMethod +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet +import org.apache.jackrabbit.webdav.property.DavPropertySet +import org.apache.jackrabbit.webdav.property.DefaultDavProperty +import org.apache.jackrabbit.webdav.xml.Namespace +import java.io.IOException + +class ToggleAlbumFavoriteRemoteOperation + @JvmOverloads + constructor( + private val makeItFavorited: Boolean, + private val filePath: String, + private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut + ) : RemoteOperation() { + @Deprecated("Deprecated in Java") + override fun run(client: OwnCloudClient): RemoteOperationResult { + // when file is in local db the remotePath will be actual path instead of albums path + // to perform operation we have to call files dav uri + if (!filePath.contains("/albums/")) { + return ToggleFavoriteRemoteOperation(makeItFavorited, filePath).execute(client) + } + + var result: RemoteOperationResult + var propPatchMethod: PropPatchMethod? = null + val newProps = DavPropertySet() + val removeProperties = DavPropertyNameSet() + val favoriteProperty = + DefaultDavProperty( + "oc:favorite", + if(this.makeItFavorited) "1" else "0", + Namespace.getNamespace(WebdavEntry.NAMESPACE_OC) + ) + newProps.add(favoriteProperty) + + val webDavUrl = "${client.davUri}/photos/" + val encodedPath = "${client.userId}${WebdavUtils.encodePath(this.filePath)}" + val fullFilePath = "$webDavUrl$encodedPath" + + try { + propPatchMethod = PropPatchMethod(fullFilePath, newProps, removeProperties) + val status = + client.executeMethod( + propPatchMethod, + sessionTimeOut.readTimeOut, + sessionTimeOut.connectionTimeOut + ) + val isSuccess = (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK) + if (isSuccess) { + result = RemoteOperationResult(true, status, propPatchMethod.responseHeaders) + } else { + client.exhaustResponse(propPatchMethod.responseBodyAsStream) + result = RemoteOperationResult(false, status, propPatchMethod.responseHeaders) + } + } catch (e: IOException) { + result = RemoteOperationResult(e) + } finally { + propPatchMethod?.releaseConnection() + } + + return result + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/services/OperationsService.java b/app/src/main/java/com/owncloud/android/services/OperationsService.java index 6923606819af..b4c2f901c226 100644 --- a/app/src/main/java/com/owncloud/android/services/OperationsService.java +++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java @@ -64,6 +64,10 @@ import com.owncloud.android.operations.UpdateShareInfoOperation; import com.owncloud.android.operations.UpdateSharePermissionsOperation; import com.owncloud.android.operations.UpdateShareViaLinkOperation; +import com.owncloud.android.operations.albums.CopyFileToAlbumOperation; +import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation; +import com.owncloud.android.operations.albums.RemoveAlbumRemoteOperation; +import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation; import java.io.IOException; import java.util.Optional; @@ -125,6 +129,11 @@ public class OperationsService extends Service { public static final String ACTION_CHECK_CURRENT_CREDENTIALS = "CHECK_CURRENT_CREDENTIALS"; public static final String ACTION_RESTORE_VERSION = "RESTORE_VERSION"; public static final String ACTION_UPDATE_FILES_DOWNLOAD_LIMIT = "UPDATE_FILES_DOWNLOAD_LIMIT"; + public static final String ACTION_CREATE_ALBUM = "CREATE_ALBUM"; + public static final String EXTRA_ALBUM_NAME = "ALBUM_NAME"; + public static final String ACTION_ALBUM_COPY_FILE = "ALBUM_COPY_FILE"; + public static final String ACTION_RENAME_ALBUM = "RENAME_ALBUM"; + public static final String ACTION_REMOVE_ALBUM = "REMOVE_ALBUM"; private ServiceHandler mOperationsHandler; private OperationsServiceBinder mOperationsBinder; @@ -774,6 +783,28 @@ private Pair newOperation(Intent operationIntent) { } break; + case ACTION_CREATE_ALBUM: + String albumName = operationIntent.getStringExtra(EXTRA_ALBUM_NAME); + operation = new CreateNewAlbumRemoteOperation(albumName); + break; + + case ACTION_ALBUM_COPY_FILE: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + newParentPath = operationIntent.getStringExtra(EXTRA_NEW_PARENT_PATH); + operation = new CopyFileToAlbumOperation(remotePath, newParentPath, fileDataStorageManager); + break; + + case ACTION_RENAME_ALBUM: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + String newAlbumName = operationIntent.getStringExtra(EXTRA_NEWNAME); + operation = new RenameAlbumRemoteOperation(remotePath, newAlbumName); + break; + + case ACTION_REMOVE_ALBUM: + String albumNameToRemove = operationIntent.getStringExtra(EXTRA_ALBUM_NAME); + operation = new RemoveAlbumRemoteOperation(albumNameToRemove); + break; + default: // do nothing break; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt new file mode 100644 index 000000000000..a417c6cc96c5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt @@ -0,0 +1,221 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.activity + +import android.content.Intent +import android.content.res.Resources +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.FragmentActivity +import com.nextcloud.client.di.Injectable +import com.owncloud.android.R +import com.owncloud.android.databinding.FilesFolderPickerBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.SearchRemoteOperation +import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation +import com.owncloud.android.ui.activity.FolderPickerActivity.Companion.TAG_LIST_OF_FOLDERS +import com.owncloud.android.ui.events.SearchEvent +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.ui.fragment.albums.AlbumsFragment +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.ErrorMessageAdapter + +class AlbumsPickerActivity : FileActivity(), FileFragment.ContainerActivity, OnEnforceableRefreshListener, Injectable { + + private var captionText: String? = null + + private var action: String? = null + + private lateinit var folderPickerBinding: FilesFolderPickerBinding + + private fun initBinding() { + folderPickerBinding = FilesFolderPickerBinding.inflate(layoutInflater) + setContentView(folderPickerBinding.root) + } + + override fun onCreate(savedInstanceState: Bundle?) { + Log_OC.d(TAG, "onCreate() start") + + super.onCreate(savedInstanceState) + + initBinding() + setupToolbar() + showHideDefaultToolbarDivider(true) + setupAction() + setupActionBar() + + if (savedInstanceState == null) { + createFragments() + } + + updateActionBarTitleAndHomeButtonByString(captionText) + } + + private fun setupActionBar() { + findViewById(R.id.sort_list_button_group).visibility = + View.GONE + findViewById(R.id.switch_grid_view_button).visibility = + View.GONE + supportActionBar?.let { actionBar -> + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setHomeButtonEnabled(true) + captionText?.let { + viewThemeUtils.files.themeActionBar(this, actionBar, it) + } + } + } + + private fun setupAction() { + action = intent.getStringExtra(EXTRA_ACTION) + setupUIForChooseButton() + } + + private fun setupUIForChooseButton() { + if (action == CHOOSE_ALBUM) { + captionText = resources.getText(R.string.album_picker_toolbar_title).toString() + } else if (action == CHOOSE_MEDIA_FILES) { + captionText = resources.getText(R.string.media_picker_toolbar_title).toString() + } + + folderPickerBinding.bottomLayout.visibility = View.GONE + folderPickerBinding.divider.visibility = View.GONE + } + + private fun createFragments() { + if (action == CHOOSE_ALBUM) { + val transaction = supportFragmentManager.beginTransaction() + transaction.add( + R.id.fragment_container, + AlbumsFragment.newInstance(isSelectionMode = true), + AlbumsFragment.TAG + ) + transaction.commit() + } else if (action == CHOOSE_MEDIA_FILES) { + createGalleryFragment() + } + } + + private fun createGalleryFragment() { + val photoFragment = GalleryFragment() + val bundle = Bundle() + bundle.putParcelable( + OCFileListFragment.SEARCH_EVENT, + SearchEvent("image/%", SearchRemoteOperation.SearchType.PHOTO_SEARCH) + ) + bundle.putBoolean(EXTRA_FROM_ALBUM, true) + photoFragment.arguments = bundle + + val transaction = supportFragmentManager.beginTransaction() + transaction.add(R.id.fragment_container, photoFragment, TAG_LIST_OF_FOLDERS) + transaction.commit() + } + + private val listOfFilesFragment: AlbumsFragment? + get() { + val listOfFiles = supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG) + + return if (listOfFiles != null) { + return listOfFiles as AlbumsFragment? + } else { + Log_OC.e(TAG, "Access to non existing list of albums fragment!!") + null + } + } + + override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) { + super.onRemoteOperationFinish(operation, result) + if (operation is CreateNewAlbumRemoteOperation) { + onCreateAlbumOperationFinish(operation, result) + } + } + + /** + * Updates the view associated to the activity after the finish of an operation trying to create a new folder. + * + * @param operation Creation operation performed. + * @param result Result of the creation. + */ + @Suppress("MaxLineLength") + private fun onCreateAlbumOperationFinish( + operation: CreateNewAlbumRemoteOperation, + result: RemoteOperationResult<*> + ) { + if (result.isSuccess) { + val fileListFragment = listOfFilesFragment + fileListFragment?.refreshAlbums() + } else { + try { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources) + ) + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + + override fun showDetails(file: OCFile?) { + // not used at the moment + } + + override fun showDetails(file: OCFile?, activeTab: Int) { + // not used at the moment + } + + override fun onBrowsedDownTo(folder: OCFile?) { + // not used at the moment + } + + override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) { + // not used at the moment + } + + companion object { + private val EXTRA_ACTION = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_ACTION") + private val CHOOSE_ALBUM = AlbumsPickerActivity::class.java.canonicalName?.plus(".CHOOSE_ALBUM") + private val CHOOSE_MEDIA_FILES = AlbumsPickerActivity::class.java.canonicalName?.plus(".CHOOSE_MEDIA_FILES") + val EXTRA_FROM_ALBUM = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_FROM_ALBUM") + val EXTRA_MEDIA_FILES_PATH = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_MEDIA_FILES_PATH") + + private val TAG = AlbumsPickerActivity::class.java.simpleName + + fun intentForPickingAlbum(context: FragmentActivity): Intent { + return Intent(context, AlbumsPickerActivity::class.java).apply { + putExtra(EXTRA_ACTION, CHOOSE_ALBUM) + } + } + + fun intentForPickingMediaFiles(context: FragmentActivity): Intent { + return Intent(context, AlbumsPickerActivity::class.java).apply { + putExtra(EXTRA_ACTION, CHOOSE_MEDIA_FILES) + } + } + } + + override fun onRefresh(enforced: Boolean) { + // do nothing + } + + override fun onRefresh() { + // do nothing + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> super.onBackPressed() + } + return super.onOptionsItemSelected(item) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index e5ca8a79c66e..770fe532fb43 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -94,6 +94,8 @@ import com.owncloud.android.ui.fragment.GroupfolderListFragment; import com.owncloud.android.ui.fragment.OCFileListFragment; import com.owncloud.android.ui.fragment.SharedListFragment; +import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment; +import com.owncloud.android.ui.fragment.albums.AlbumsFragment; import com.owncloud.android.ui.preview.PreviewTextStringFragment; import com.owncloud.android.ui.trashbin.TrashbinActivity; import com.owncloud.android.utils.BitmapUtils; @@ -126,6 +128,7 @@ import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hct.Hct; import kotlin.Unit; @@ -272,11 +275,7 @@ private void handleBottomNavigationViewClicks() { resetOnlyPersonalAndOnDevice(); if (menuItemId == R.id.nav_all_files) { - showFiles(false,false); - if (this instanceof FileDisplayActivity fda) { - fda.browseToRoot(); - } - EventBus.getDefault().post(new ChangeMenuEvent()); + onNavigationItemClicked(menuItem); } else if (menuItemId == R.id.nav_favorites) { setupToolbar(); handleSearchEvents(new SearchEvent("", SearchRemoteOperation.SearchType.FAVORITE_SEARCH), menuItemId); @@ -285,6 +284,8 @@ private void handleBottomNavigationViewClicks() { } else if (menuItemId == R.id.nav_gallery) { setupToolbar(); startPhotoSearch(menuItem.getItemId()); + } else if (menuItemId == R.id.nav_album) { + replaceAlbumFragment(); } // Remove extra icon from the action bar @@ -572,7 +573,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) { !(fda.getLeftFragment() instanceof GalleryFragment) && !(fda.getLeftFragment() instanceof SharedListFragment) && !(fda.getLeftFragment() instanceof GroupfolderListFragment) && - !(fda.getLeftFragment() instanceof PreviewTextStringFragment)) { + !(fda.getLeftFragment() instanceof PreviewTextStringFragment) && + !isAlbumsFragment() && !isAlbumItemsFragment()) { showFiles(false, itemId == R.id.nav_personal_files); fda.browseToRoot(); EventBus.getDefault().post(new ChangeMenuEvent()); @@ -594,6 +596,17 @@ private void onNavigationItemClicked(final MenuItem menuItem) { resetOnlyPersonalAndOnDevice(); setupToolbar(); startPhotoSearch(menuItem.getItemId()); + } else if (itemId == R.id.nav_album) { + if (this instanceof FileDisplayActivity) { + replaceAlbumFragment(); + } else { + // when user is not on FileDisplayActivity + // if user is on TrashbinActivity then we have to start activity again + Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setAction(FileDisplayActivity.ALBUMS); + startActivity(intent); + } } else if (itemId == R.id.nav_on_device) { EventBus.getDefault().post(new ChangeMenuEvent()); showFiles(true, false); @@ -617,7 +630,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) { resetOnlyPersonalAndOnDevice(); menuItemId = Menu.NONE; MenuItem isNewMenuItemChecked = menuItem.setChecked(false); - Log_OC.d(TAG,"onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked); + Log_OC.d(TAG, "onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked); final Optional optionalUser = getUser(); if (optionalUser.isPresent()) { UserInfoActivity.openAccountRemovalDialog(optionalUser.get(), getSupportFragmentManager()); @@ -647,6 +660,26 @@ private void onNavigationItemClicked(final MenuItem menuItem) { } } + public void replaceAlbumFragment() { + if (isAlbumsFragment()) { + return; + } + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + transaction.addToBackStack(null); + transaction.replace(R.id.left_fragment_container, AlbumsFragment.Companion.newInstance(false), AlbumsFragment.Companion.getTAG()); + transaction.commit(); + } + + public boolean isAlbumsFragment() { + Fragment albumsFragment = getSupportFragmentManager().findFragmentByTag(AlbumsFragment.Companion.getTAG()); + return albumsFragment instanceof AlbumsFragment && albumsFragment.isVisible(); + } + + public boolean isAlbumItemsFragment() { + Fragment albumItemsFragment = getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG()); + return albumItemsFragment instanceof AlbumItemsFragment && albumItemsFragment.isVisible(); + } + private void startComposeActivity(ComposeDestination destination, int titleId) { Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class); composeActivity.putExtra(ComposeActivity.DESTINATION, destination.getId()); @@ -711,7 +744,8 @@ public void startPhotoSearch(int id) { private void handleSearchEvents(SearchEvent searchEvent, int menuItemId) { if (this instanceof FileDisplayActivity) { final Fragment leftFragment = ((FileDisplayActivity) this).getLeftFragment(); - if (leftFragment instanceof GalleryFragment || leftFragment instanceof SharedListFragment) { + if (leftFragment instanceof GalleryFragment || leftFragment instanceof SharedListFragment + || isAlbumsFragment() || isAlbumItemsFragment()) { launchActivityForSearch(searchEvent, menuItemId); } else { EventBus.getDefault().post(searchEvent); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java index 1e1727283d18..6260636552a6 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -91,6 +91,8 @@ import com.owncloud.android.ui.fragment.FileDetailFragment; import com.owncloud.android.ui.fragment.FileDetailSharingFragment; import com.owncloud.android.ui.fragment.OCFileListFragment; +import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment; +import com.owncloud.android.ui.fragment.albums.AlbumsFragment; import com.owncloud.android.ui.fragment.filesRepository.FilesRepository; import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository; import com.owncloud.android.ui.helpers.FileOperationsHelper; @@ -107,6 +109,7 @@ import java.lang.ref.WeakReference; import java.util.ArrayList; +import java.util.Objects; import javax.inject.Inject; @@ -821,11 +824,19 @@ private void onUpdateShareInformation(RemoteOperationResult result, @StringRes i } public void refreshList() { - final Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); - if (fragment instanceof OCFileListFragment listFragment) { - listFragment.onRefresh(); - } else if (fragment instanceof FileDetailFragment detailFragment) { - detailFragment.goBackToOCFileListFragment(); + // NMC Customization + // first check for album fragments + if (isAlbumsFragment()) { + ((AlbumsFragment) Objects.requireNonNull(getSupportFragmentManager().findFragmentByTag(AlbumsFragment.Companion.getTAG()))).refreshAlbums(); + } else if (isAlbumItemsFragment()) { + ((AlbumItemsFragment) Objects.requireNonNull(getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG()))).refreshData(); + } else { + final Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); + if (fragment instanceof OCFileListFragment listFragment) { + listFragment.onRefresh(); + } else if (fragment instanceof FileDetailFragment detailFragment) { + detailFragment.goBackToOCFileListFragment(); + } } } 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 30b20ac632da..d0ec6f7b126d 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 @@ -111,6 +111,10 @@ import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.RenameFileOperation import com.owncloud.android.operations.SynchronizeFileOperation import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.albums.CopyFileToAlbumOperation +import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation +import com.owncloud.android.operations.albums.RemoveAlbumRemoteOperation +import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation import com.owncloud.android.syncadapter.FileSyncAdapter import com.owncloud.android.ui.CompletionCallback import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask @@ -136,6 +140,8 @@ import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.ui.fragment.SharedListFragment import com.owncloud.android.ui.fragment.TaskRetainerFragment import com.owncloud.android.ui.fragment.UnifiedSearchFragment +import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment +import com.owncloud.android.ui.fragment.albums.AlbumsFragment import com.owncloud.android.ui.helpers.FileOperationsHelper import com.owncloud.android.ui.helpers.UriUploader import com.owncloud.android.ui.interfaces.TransactionInterface @@ -574,6 +580,12 @@ class FileDisplayActivity : leftFragment = GroupfolderListFragment() supportFragmentManager.executePendingTransactions() } + ALBUMS == action -> { + Log_OC.d(this, "Switch to list albums fragment") + menuItemId = R.id.nav_album + replaceAlbumFragment() + supportFragmentManager.executePendingTransactions() + } } } @@ -945,6 +957,7 @@ class FileDisplayActivity : !isSearchOpen() && isRoot(getCurrentDir()) && this.leftFragment is OCFileListFragment + && !isAlbumItemsFragment() /** * Called, when the user selected something for uploading @@ -1167,6 +1180,12 @@ class FileDisplayActivity : onBackPressedDispatcher.onBackPressed() } + // NMC Customization: pop back if current fragment is AlbumItemsFragment + isAlbumItemsFragment() -> { + isEnabled = false + popBack() + } + leftFragment is OCFileListFragment -> { val fragment = leftFragment as OCFileListFragment @@ -1722,6 +1741,14 @@ class FileDisplayActivity : } } } + + // NMC Customization + // notify when upload is finshed and user is on albums screen + if (isAlbumsFragment()) { + (supportFragmentManager.findFragmentByTag(AlbumsFragment.Companion.TAG) as AlbumsFragment).refreshAlbums() + } else if (isAlbumItemsFragment()) { + (supportFragmentManager.findFragmentByTag(AlbumItemsFragment.Companion.TAG) as AlbumItemsFragment).refreshData() + } } // TODO refactor this receiver, and maybe DownloadFinishReceiver; this method is duplicated :S @@ -2060,6 +2087,22 @@ class FileDisplayActivity : is RestoreFileVersionRemoteOperation -> { onRestoreFileVersionOperationFinish(result) } + + is CreateNewAlbumRemoteOperation -> { + onCreateAlbumOperationFinish(operation, result) + } + + is CopyFileToAlbumOperation -> { + onCopyAlbumFileOperationFinish(operation, result) + } + + is RenameAlbumRemoteOperation -> { + onRenameAlbumOperationFinish(operation, result) + } + + is RemoveAlbumRemoteOperation -> { + onRemoveAlbumOperationFinish(operation, result) + } } } @@ -2308,6 +2351,94 @@ class FileDisplayActivity : } } + private fun onRemoveAlbumOperationFinish(operation: RemoveAlbumRemoteOperation, result: RemoteOperationResult<*>) { + if (result.isSuccess) { + + val fragment = supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG) + if (fragment is AlbumItemsFragment) { + fragment.onAlbumDeleted() + } + } else { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + + if (result.isSslRecoverableException) { + mLastSslUntrustedServerResult = result + showUntrustedCertDialog(mLastSslUntrustedServerResult) + } + } + } + + private fun onCopyAlbumFileOperationFinish(operation: CopyFileToAlbumOperation, result: RemoteOperationResult<*>) { + if (result.isSuccess) { + // when item added from inside of Album + val fragment = supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG) + if (fragment is AlbumItemsFragment) { + fragment.refreshData() + } else { + // files added directly from Media tab + DisplayUtils.showSnackMessage(this, getResources().getString(R.string.album_file_added_message)) + } + Log_OC.e(TAG, "Files copied successfully") + } else { + try { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + + private fun onRenameAlbumOperationFinish(operation: RenameAlbumRemoteOperation, result: RemoteOperationResult<*>) { + if (result.isSuccess) { + + val fragment = supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG) + if (fragment is AlbumItemsFragment) { + fragment.onAlbumRenamed(operation.newAlbumName) + } + } else { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + + if (result.isSslRecoverableException) { + mLastSslUntrustedServerResult = result + showUntrustedCertDialog(mLastSslUntrustedServerResult) + } + } + } + + private fun onCreateAlbumOperationFinish( + operation: CreateNewAlbumRemoteOperation, + result: RemoteOperationResult<*> + ) { + if (result.isSuccess) { + val fragment = supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG) + if (fragment is AlbumsFragment) { + fragment.navigateToAlbumItemsFragment(operation.newAlbumName, true) + } + } else { + try { + if (RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS == result.code) { + DisplayUtils.showSnackMessage(this, R.string.album_already_exists) + } else { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + } + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + /** * {@inheritDoc} */ @@ -2732,7 +2863,10 @@ class FileDisplayActivity : val ocFileListFragment = this.listOfFilesFragment if (ocFileListFragment != null && (ocFileListFragment !is GalleryFragment) && - (ocFileListFragment !is SharedListFragment) + (ocFileListFragment !is SharedListFragment) && + // album fragment check will help in showing offline files screen + // when navigating from Albums to Offline Files + !isAlbumsFragment && !isAlbumItemsFragment ) { ocFileListFragment.refreshDirectory() } else { @@ -3070,6 +3204,7 @@ class FileDisplayActivity : const val RESTART: String = "RESTART" const val ALL_FILES: String = "ALL_FILES" const val LIST_GROUPFOLDERS: String = "LIST_GROUPFOLDERS" + const val ALBUMS: String = "ALBUMS" const val SINGLE_USER_SIZE: Int = 1 const val OPEN_FILE: String = "NC_OPEN_FILE" diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index 0c84fe6c84e3..18e0b83422c2 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -253,6 +253,12 @@ class GalleryAdapter( notifyDataSetChanged() } + @SuppressLint("NotifyDataSetChanged") + fun showAlbumItems(albumItems: List) { + files = albumItems.toGalleryItems() + notifyDataSetChanged() + } + private fun transformToRows(list: List): List { if (list.isEmpty()) return emptyList() @@ -314,6 +320,10 @@ class GalleryAdapter( } } + fun setCheckedItem(files: Set?) { + ocFileListDelegate.setCheckedItem(files) + } + override fun setMultiSelect(boolean: Boolean) { ocFileListDelegate.isMultiSelect = boolean } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt index 7cc6c4b59e6f..ef4b8d92f081 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -35,6 +35,8 @@ import com.owncloud.android.ui.activity.ComponentsGetter import com.owncloud.android.ui.activity.FolderPickerActivity import com.owncloud.android.ui.fragment.GalleryFragment import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment +import com.owncloud.android.ui.activity.AlbumsPickerActivity import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.EncryptionUtils @@ -160,8 +162,16 @@ class OCFileListDelegate( GalleryImageGenerationJob.storeJob(job, imageView) imageView.setOnClickListener { - ocFileListFragmentInterface.onItemClicked(file) - GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition) + // NMC Customization: while picking media directly perform long click + if (context is AlbumsPickerActivity) { + ocFileListFragmentInterface.onLongItemClicked( + file + ) + } else { + ocFileListFragmentInterface.onItemClicked(file) + GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition) + AlbumItemsFragment.lastMediaItemPosition = galleryRowHolder.absoluteAdapterPosition + } } if (!hideItemOptions) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt new file mode 100644 index 000000000000..e30ef5b41cd9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.albums + +import com.owncloud.android.operations.albums.PhotoAlbumEntry + +interface AlbumFragmentInterface { + fun onItemClick(album: PhotoAlbumEntry) +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt new file mode 100644 index 000000000000..4d7e5c6ca5d0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.albums + +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.owncloud.android.databinding.AlbumsGridItemBinding + +internal class AlbumGridItemViewHolder(private var binding: AlbumsGridItemBinding) : + RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder { + override val thumbnail: ImageView + get() = binding.thumbnail + override val shimmerThumbnail: LoaderImageView + get() = binding.thumbnailShimmer + override val albumName: TextView + get() = binding.Filename + override val albumInfo: TextView + get() = binding.fileInfo +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt new file mode 100644 index 000000000000..a531404e8e99 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.albums + +import android.widget.ImageView +import android.widget.TextView +import com.elyeproj.loaderviewlibrary.LoaderImageView + +interface AlbumItemViewHolder { + val thumbnail: ImageView + val shimmerThumbnail: LoaderImageView + val albumName: TextView + val albumInfo: TextView +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt new file mode 100644 index 000000000000..99ff63eb9a01 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.albums + +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.owncloud.android.databinding.AlbumsListItemBinding + +internal class AlbumListItemViewHolder(private var binding: AlbumsListItemBinding) : + RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder { + override val thumbnail: ImageView + get() = binding.thumbnail + override val shimmerThumbnail: LoaderImageView + get() = binding.thumbnailShimmer + override val albumName: TextView + get() = binding.Filename + override val albumInfo: TextView + get() = binding.fileInfo +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt new file mode 100644 index 000000000000..6598a0f77d83 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt @@ -0,0 +1,125 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.albums + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.account.User +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import com.owncloud.android.databinding.AlbumsGridItemBinding +import com.owncloud.android.databinding.AlbumsListItemBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.albums.PhotoAlbumEntry +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils + +@Suppress("LongParameterList") +class AlbumsAdapter( + val context: Context, + private val storageManager: FileDataStorageManager?, + private val user: User, + private val albumFragmentInterface: AlbumFragmentInterface, + private val syncedFolderProvider: SyncedFolderProvider, + private val preferences: AppPreferences, + private val viewThemeUtils: ViewThemeUtils, + private val gridView: Boolean = true +) : + RecyclerView.Adapter() { + private var albumList: MutableList = mutableListOf() + private val asyncTasks: MutableList = ArrayList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return if (gridView) { + AlbumGridItemViewHolder(AlbumsGridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } else { + AlbumListItemViewHolder(AlbumsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + } + + override fun getItemCount(): Int { + return albumList.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val gridViewHolder = holder as AlbumItemViewHolder + val file: PhotoAlbumEntry = albumList[position] + + gridViewHolder.albumName.text = file.albumName + gridViewHolder.thumbnail.tag = file.lastPhoto + gridViewHolder.albumInfo.text = String.format( + context.resources.getString(R.string.album_items_text), + file.nbItems, + file.createdDate + ) + + if (file.lastPhoto > 0) { + var ocLocal = storageManager?.getFileByLocalId(file.lastPhoto) + if (ocLocal == null) { + // if local file is not present make dummy file with fake remotePath + // without remotePath it won't work + // lastPhoto is file id which we can set it to localId and remoteId for thumbnail generation + val nFile = OCFile("/" + file.albumName) + nFile.localId = file.lastPhoto + nFile.remoteId = file.lastPhoto.toString() + ocLocal = nFile + } + DisplayUtils.setThumbnail( + ocLocal, + gridViewHolder.thumbnail, + user, + storageManager, + asyncTasks, + gridView, + context, + gridViewHolder.shimmerThumbnail, + preferences, + viewThemeUtils, + syncedFolderProvider, + // used to avoid build issue + true + ) + } else { + gridViewHolder.thumbnail.setImageResource(R.drawable.album_no_photo_placeholder) + gridViewHolder.thumbnail.visibility = View.VISIBLE + gridViewHolder.shimmerThumbnail.visibility = View.GONE + } + + holder.itemView.setOnClickListener { albumFragmentInterface.onItemClick(file) } + } + + fun cancelAllPendingTasks() { + for (task in asyncTasks) { + task.cancel(true) + if (task.getMethod != null) { + Log_OC.d("AlbumsAdapter", "cancel: abort get method directly") + task.getMethod.abort() + } + } + asyncTasks.clear() + } + + @SuppressLint("NotifyDataSetChanged") + fun setAlbumItems(albumItems: List?) { + albumList.clear() + albumItems?.let { + // NMC-4843 fix + // alphabetically sorting + albumList.addAll(it.sortedBy { album -> album.albumName.lowercase() }) + } + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java index 01d683a71b1e..ca13bd6223bd 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java @@ -18,6 +18,7 @@ import android.net.Uri; import android.os.AsyncTask; import android.provider.DocumentsContract; +import android.text.TextUtils; import android.widget.Toast; import com.nextcloud.client.account.User; @@ -55,6 +56,9 @@ public class CopyAndUploadContentUrisTask extends AsyncTask @@ -98,9 +102,10 @@ public static Object[] makeParamsToExecute( }; } - public CopyAndUploadContentUrisTask(OnCopyTmpFilesTaskListener listener, Context context) { + public CopyAndUploadContentUrisTask(OnCopyTmpFilesTaskListener listener, Context context, String albumName) { mListener = new WeakReference<>(listener); mAppContext = context.getApplicationContext(); + mAlbumName = albumName; } /** @@ -180,16 +185,31 @@ protected ResultCode doInBackground(Object[] params) { } } - FileUploadHelper.Companion.instance().uploadNewFiles( - user, - localPaths, - currentRemotePaths, - behaviour, - false, // do not create parent folder if not existent - UploadFileOperation.CREATED_BY_USER, - false, - false, - NameCollisionPolicy.ASK_USER); + if (TextUtils.isEmpty(mAlbumName)) { + FileUploadHelper.Companion.instance().uploadNewFiles( + user, + localPaths, + currentRemotePaths, + behaviour, + false, // do not create parent folder if not existent + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.ASK_USER); + } else { + FileUploadHelper.Companion.instance().uploadAndCopyNewFilesForAlbum( + user, + localPaths, + currentRemotePaths, + mAlbumName, + behaviour, + true, // create parent folder if not existent + UploadFileOperation.CREATED_BY_USER, + false, + false, + // use RENAME policy to make sure all files are uploaded + NameCollisionPolicy.RENAME); + } result = ResultCode.OK; diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt new file mode 100644 index 000000000000..4b9986d1c6ff --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt @@ -0,0 +1,198 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.ui.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.extensions.typedActivity +import com.nmc.android.utils.DialogThemeUtils +import com.owncloud.android.R +import com.owncloud.android.databinding.EditBoxDialogBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.ui.activity.ComponentsGetter +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.KeyboardUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +/** + * Dialog to input the name for a new folder to create. + * + * + * Triggers the folder creation when name is confirmed. + */ +class CreateAlbumDialogFragment : DialogFragment(), DialogInterface.OnClickListener, Injectable { + + @Inject + lateinit var fileDataStorageManager: FileDataStorageManager + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var keyboardUtils: KeyboardUtils + + @Inject + lateinit var connectivityService: ConnectivityService + + @Inject + lateinit var accountProvider: CurrentAccountProvider + + private var positiveButton: MaterialButton? = null + + private lateinit var binding: EditBoxDialogBinding + + private var albumName: String? = null + + override fun onStart() { + super.onStart() + bindButton() + } + + private fun bindButton() { + val dialog = dialog + + if (dialog is AlertDialog) { + positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton + positiveButton?.let { + it.isEnabled = false + } + } + } + + override fun onResume() { + super.onResume() + bindButton() + keyboardUtils.showKeyboardForEditText(requireDialog().window, binding.userInput) + } + + @Suppress("EmptyFunctionBlock") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + albumName = arguments?.getString(ARG_ALBUM_NAME) + + val inflater = requireActivity().layoutInflater + binding = EditBoxDialogBinding.inflate(inflater, null, false) + + binding.userInput.setText(albumName ?: "") + albumName?.let { + binding.userInput.setSelection(0, it.length) + } + + binding.userInput.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable) {} + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + checkFileNameAfterEachType() + } + }) + + val builder = buildMaterialAlertDialog(binding.root) + // NMC customization + DialogThemeUtils.colorMaterialAlertDialogBackground(binding.userInputContainer.context, builder) + return builder.create() + } + + private fun checkFileNameAfterEachType() { + val newAlbumName = binding.userInput.text?.toString() ?: "" + + val errorMessage = when { + newAlbumName.isBlank() -> getString(R.string.album_name_empty) + else -> null + } + + if (errorMessage != null) { + binding.userInputContainer.error = errorMessage + positiveButton?.isEnabled = false + if (positiveButton == null) { + bindButton() + } + } else { + binding.userInputContainer.error = null + binding.userInputContainer.isErrorEnabled = false + positiveButton?.isEnabled = true + } + } + + private fun buildMaterialAlertDialog(view: View): MaterialAlertDialogBuilder { + return MaterialAlertDialogBuilder(requireActivity()) + .setView(view) + .setPositiveButton( + if (albumName == null) R.string.folder_confirm_create else R.string.rename_dialog_button, + this + ) + .setNegativeButton(R.string.common_cancel, this) + .setTitle(if (albumName == null) R.string.create_album_dialog_title else R.string.rename_album_dialog_title) + .setMessage(R.string.create_album_dialog_message) + } + + override fun onClick(dialog: DialogInterface, which: Int) { + if (which == AlertDialog.BUTTON_POSITIVE) { + val newAlbumName = (getDialog()?.findViewById(R.id.user_input) as TextView) + .text.toString().trim() + + val errorMessage = when { + newAlbumName.isBlank() -> getString(R.string.album_name_empty) + else -> null + } + + if (errorMessage != null) { + DisplayUtils.showSnackMessage(requireActivity(), errorMessage) + return + } + + connectivityService.isNetworkAndServerAvailable { result -> + if (result) { + if (albumName != null) { + typedActivity()?.fileOperationsHelper?.renameAlbum(albumName, newAlbumName) + } else { + typedActivity()?.fileOperationsHelper?.createAlbum(newAlbumName) + } + } else { + DisplayUtils.showSnackMessage(requireActivity(), getString(R.string.offline_mode)) + } + } + } + } + + companion object { + val TAG: String = CreateAlbumDialogFragment::class.java.simpleName + private const val ARG_ALBUM_NAME = "album_name" + + /** + * Public factory method to create new CreateFolderDialogFragment instances. + * + * @return Dialog ready to show. + */ + @JvmStatic + fun newInstance(albumName: String? = null): CreateAlbumDialogFragment { + return CreateAlbumDialogFragment().apply { + val argsBundle = bundleOf( + ARG_ALBUM_NAME to albumName + ) + arguments = argsBundle + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt index 54bbad82e7ab..7ac55bbd4e5f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt @@ -163,7 +163,9 @@ open class ExtendedListFragment : @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - val item = menu.findItem(R.id.action_search) + // NMC Customization: while picking Media files from Gallery Fragment through AlbumPickerActivity + // there will be no search option so it we have to return it + val item = menu.findItem(R.id.action_search) ?: return searchView = item.actionView as SearchView? viewThemeUtils.androidx.themeToolbarSearchView(searchView!!) closeButton = searchView?.findViewById(androidx.appcompat.R.id.search_close_btn) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java index 094c03da6464..953da167ccd6 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java @@ -8,6 +8,7 @@ */ package com.owncloud.android.ui.fragment; +import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -15,6 +16,7 @@ import android.content.res.Configuration; import android.os.AsyncTask; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -22,6 +24,7 @@ import android.view.View; import android.view.ViewGroup; +import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.extensions.IntentExtensionsKt; import com.owncloud.android.BuildConfig; import com.owncloud.android.R; @@ -37,9 +40,17 @@ import com.owncloud.android.ui.adapter.GalleryAdapter; import com.owncloud.android.ui.asynctasks.GallerySearchTask; import com.owncloud.android.ui.events.ChangeMenuEvent; +import com.owncloud.android.ui.fragment.albums.AlbumsFragment; +import com.owncloud.android.ui.activity.AlbumsPickerActivity; +import com.owncloud.android.utils.DisplayUtils; + +import java.util.ArrayList; +import java.util.Set; import javax.inject.Inject; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.localbroadcastmanager.content.LocalBroadcastManager; @@ -66,10 +77,15 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme private GalleryFragmentBottomSheetDialog galleryFragmentBottomSheetDialog; @Inject FileDataStorageManager fileDataStorageManager; + @Inject ConnectivityService connectivityService; private final static int maxColumnSizeLandscape = 5; private final static int maxColumnSizePortrait = 2; private int columnSize; + // NMC: required for Albums + private Set checkedFiles; + private boolean isFromAlbum; // when opened from Albums to add items + protected void setPhotoSearchQueryRunning(boolean value) { this.photoSearchQueryRunning = value; } @@ -83,7 +99,12 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); searchFragment = true; - setHasOptionsMenu(true); + if (getArguments() != null) { + isFromAlbum = getArguments().getBoolean(AlbumsPickerActivity.Companion.getEXTRA_FROM_ALBUM(), false); + } + + // NMC Customization: only show menu when not opened from media picker + setHasOptionsMenu(!isFromAlbum); if (galleryFragmentBottomSheetDialog == null) { galleryFragmentBottomSheetDialog = new GalleryFragmentBottomSheetDialog(this); @@ -405,6 +426,11 @@ public void showAllGalleryItems() { } private void updateSubtitle(GalleryFragmentBottomSheetDialog.MediaState mediaState) { + // NMC Customization: while picking media don't show subtitle + if (isFromAlbum) { + return; + } + requireActivity().runOnUiThread(() -> { if (!isAdded()) { return; @@ -431,4 +457,48 @@ protected void setGridViewColumns(float scaleFactor) { public void markAsFavorite(String remotePath, boolean favorite) { mAdapter.markAsFavorite(remotePath, favorite); } + + final ActivityResultLauncher activityResult = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), intentResult -> { + if (Activity.RESULT_OK == intentResult.getResultCode() && intentResult.getData() != null) { + String albumName = intentResult.getData().getStringExtra(AlbumsFragment.ARG_SELECTED_ALBUM_NAME); + Log_OC.e(TAG, "Selected album name: " + albumName); + addFilesToAlbum(albumName); + } + }); + + public void addImagesToAlbum(Set checkedFiles) { + this.checkedFiles = checkedFiles; + if (isFromAlbum) { + addFilesToAlbum(null); + } else { + activityResult.launch(AlbumsPickerActivity.Companion.intentForPickingAlbum(requireActivity())); + } + } + + private void addFilesToAlbum(@Nullable String albumName) { + connectivityService.isNetworkAndServerAvailable(result -> { + if (result) { + if (checkedFiles == null || checkedFiles.isEmpty()) { + return; + } + final ArrayList paths = new ArrayList<>(checkedFiles.size()); + for (OCFile file : checkedFiles) { + paths.add(file.getRemotePath()); + } + checkedFiles = null; + exitSelectionMode(); + if (!TextUtils.isEmpty(albumName)) { + mContainerActivity.getFileOperationsHelper().albumCopyFiles(paths, albumName); + } else { + Intent resultIntent = new Intent(); + resultIntent.putStringArrayListExtra(AlbumsPickerActivity.Companion.getEXTRA_MEDIA_FILES_PATH(), paths); + requireActivity().setResult(Activity.RESULT_OK, resultIntent); + requireActivity().finish(); + } + } else { + DisplayUtils.showSnackMessage(requireActivity(), getString(R.string.offline_mode)); + } + }); + } } 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 25a1d1a0b192..127962ee32f9 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 @@ -22,7 +22,10 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.text.Spannable; +import android.text.SpannableString; import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; import android.util.Log; import android.util.Pair; import android.view.ActionMode; @@ -79,6 +82,7 @@ import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation; import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.ui.activity.AlbumsPickerActivity; import com.owncloud.android.ui.activity.DrawerActivity; import com.owncloud.android.ui.activity.FileActivity; import com.owncloud.android.ui.activity.FileDisplayActivity; @@ -833,6 +837,27 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { // hide FAB in multi selection mode setFabVisible(false); + if (OCFileListFragment.this instanceof GalleryFragment) { + final MenuItem addAlbumItem = menu.findItem(R.id.add_to_album); + // show add to album button for gallery to add media to Album + addAlbumItem.setVisible(true); + if (addAlbumItem.getTitle() != null) { + SpannableString coloredTitle = new SpannableString(addAlbumItem.getTitle()); + coloredTitle.setSpan( + new ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.primary)), + 0, + addAlbumItem.getTitle().length(), + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ); + addAlbumItem.setTitle(coloredTitle); + } + + // hide the 3 dot menu icon while picking media for Albums + if (requireActivity() instanceof AlbumsPickerActivity) { + item.setVisible(false); + } + } + getCommonAdapter().setMultiSelect(true); return true; } @@ -869,6 +894,10 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { final Set checkedFiles = getCommonAdapter().getCheckedItems(); if (item.getItemId() == R.id.custom_menu_placeholder_item) { openActionsMenu(getCommonAdapter().getFilesCount(), checkedFiles, false); + } else if (item.getItemId() == R.id.add_to_album){ + if (OCFileListFragment.this instanceof GalleryFragment galleryFragment) { + galleryFragment.addImagesToAlbum(checkedFiles); + } } return true; } @@ -2135,6 +2164,14 @@ protected void setTitle(@StringRes final int title) { protected void setTitle(final String title, Boolean showBackAsMenu) { requireActivity().runOnUiThread(() -> { if (getActivity() != null) { + // NMC region + // NMC-5040 fix + // skip updating title if user is on Albums screen + if (((FileDisplayActivity) getActivity()).isAlbumsFragment() + || ((FileDisplayActivity) getActivity()).isAlbumItemsFragment()) { + return; + } + // endregion final ActionBar actionBar = ((FileDisplayActivity) getActivity()).getSupportActionBar(); final Context context = getContext(); @@ -2275,6 +2312,14 @@ public void setFabVisible(final boolean visible) { return; } + // NMC Customizations: to hide the fab if user is on Albums Fragment + if (requireActivity() instanceof FileDisplayActivity fda + && (fda.isAlbumsFragment() + || fda.isAlbumItemsFragment())) { + mFabMain.hide(); + return; + } + final var activity = getActivity(); if (activity == null) { return; diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt new file mode 100644 index 000000000000..fab2ee759741 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -0,0 +1,1173 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.albums + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Activity.RESULT_OK +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Parcelable +import android.view.ActionMode +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.AbsListView +import android.widget.ImageView +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.IdRes +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.core.view.get +import androidx.core.view.size +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.appbar.AppBarLayout +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.Throttler +import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet +import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance +import com.nextcloud.utils.extensions.isDialogFragmentReady +import com.nmc.android.utils.SwipeRefreshThemeUtils +import com.owncloud.android.R +import com.owncloud.android.databinding.AlbumsFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.db.ProviderMeta +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.albums.ReadAlbumItemsRemoteOperation +import com.owncloud.android.operations.albums.RemoveAlbumFileRemoteOperation +import com.owncloud.android.operations.albums.ToggleAlbumFavoriteRemoteOperation +import com.owncloud.android.ui.activity.AlbumsPickerActivity +import com.owncloud.android.ui.activity.AlbumsPickerActivity.Companion.intentForPickingMediaFiles +import com.owncloud.android.ui.activity.FileActivity.REQUEST_CODE__LAST_SHARED +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.adapter.GalleryAdapter +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment +import com.owncloud.android.ui.events.FavoriteEvent +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.helpers.UriUploader +import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface +import com.owncloud.android.ui.preview.PreviewImageFragment +import com.owncloud.android.ui.preview.PreviewMediaActivity.Companion.canBePreviewed +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.ErrorMessageAdapter +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.util.Optional +import java.util.function.Supplier +import javax.inject.Inject + +@Suppress("TooManyFunctions") +class AlbumItemsFragment : Fragment(), OCFileListFragmentInterface, Injectable { + + private var adapter: GalleryAdapter? = null + private var client: OwnCloudClient? = null + private var optionalUser: Optional? = null + + private lateinit var binding: AlbumsFragmentBinding + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var throttler: Throttler + + private var mContainerActivity: FileFragment.ContainerActivity? = null + + private var columnSize = 0 + + private lateinit var albumName: String + private var isNewAlbum: Boolean = false + + private var mMultiChoiceModeListener: MultiChoiceModeListener? = null + + private var albumRemoteFileList = listOf() + + private val refreshFlow = MutableSharedFlow(extraBufferCapacity = 1) + + override fun onAttach(context: Context) { + super.onAttach(context) + try { + mContainerActivity = context as FileFragment.ContainerActivity + } catch (e: ClassCastException) { + throw IllegalArgumentException( + context.toString() + " must implement " + + FileFragment.ContainerActivity::class.java.simpleName, + e + ) + } + arguments?.let { + albumName = it.getString(ARG_ALBUM_NAME) ?: "" + isNewAlbum = it.getBoolean(ARG_IS_NEW_ALBUM) + } + } + + override fun onDetach() { + mContainerActivity = null + super.onDetach() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + columnSize = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + MAX_COLUMN_SIZE_LANDSCAPE + } else { + MAX_COLUMN_SIZE_PORTRAIT + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = AlbumsFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + @OptIn(FlowPreview::class) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + optionalUser = Optional.of(accountManager.user) + showAppBar() + createMenu() + setupContainingList() + setupContent() + + // if fragment is opened when new albums is created + // then open gallery to choose media to add + if (isNewAlbum) { + openGalleryToAddMedia() + } + + setUpEmptyView() + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + refreshFlow.onStart { emit(Unit) } // default fetch + .onEach { binding.swipeContainingList.isRefreshing = true } // show progress on each call + .debounce(500L) // debounce background triggers + .collect { + fetchAndSetData() + } + } + } + } + + // NMC-4790 fix + private fun showAppBar() { + if (requireActivity() is FileDisplayActivity) { + val appBarLayout = requireActivity().findViewById(R.id.appbar) + appBarLayout?.setExpanded(true, false) + } + } + + private fun setUpEmptyView() { + binding.albumEmptyView.albumsBgImage.setImageResource(R.drawable.empty_album_detailed_view) + binding.albumEmptyView.albumsBgImage.scaleType = ImageView.ScaleType.FIT_CENTER + binding.albumEmptyView.emptyAlbumLabel.text = resources.getString(R.string.empty_album_detailed_view_title) + binding.albumEmptyView.emptyAlbumMessageLabel.text = + resources.getString(R.string.empty_album_detailed_view_message) + binding.albumEmptyView.createAlbum.text = resources.getString(R.string.add_photos) + + binding.albumEmptyView.createAlbum.setOnClickListener { + // open Gallery fragment as selection then add items to current album + openGalleryToAddMedia() + } + } + + private fun setUpActionMode() { + if (mMultiChoiceModeListener != null) return + + mMultiChoiceModeListener = MultiChoiceModeListener( + requireActivity(), + adapter, + viewThemeUtils + ) { filesCount, checkedFiles -> openActionsMenu(filesCount, checkedFiles) } + (requireActivity() as FileDisplayActivity).addDrawerListener(mMultiChoiceModeListener) + } + + private fun createMenu() { + val menuHost: MenuHost = requireActivity() + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.clear() // important: clears any existing activity menu + menuInflater.inflate(R.menu.fragment_album_items, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_three_dot_icon -> { + openAlbumActionsMenu() + true + } + + R.id.action_add_from_camera_roll -> { + // we don't want quick media access bottom sheet for Android 13+ devices + // to avoid that we are not using image/* and video/* mime types + // we are validating mime types when selection is made + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + setType("*/*") + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + addCategory(Intent.CATEGORY_OPENABLE) + } + startActivityForResult( + Intent.createChooser(intent, getString(R.string.upload_chooser_title)), + REQUEST_CODE__SELECT_MEDIA_FROM_APPS + ) + true + } + + R.id.action_add_from_account -> { + // open Gallery fragment as selection then add items to current album + openGalleryToAddMedia() + true + } + + else -> false + } + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + for (i in 0 until menu.size) { + val item = menu[i] + item.icon?.let { + item.setIcon( + viewThemeUtils.platform.colorDrawable( + it, + ContextCompat.getColor(requireContext(), R.color.fontAppbar) + ) + ) + } + } + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private fun openAlbumActionsMenu() { + throttler.run("overflowClick") { + val supportFragmentManager = requireActivity().supportFragmentManager + + AlbumItemActionsBottomSheet.newInstance() + .setResultListener( + supportFragmentManager, + this + ) { id: Int -> + onAlbumActionChosen(id) + } + .show(supportFragmentManager, "album_actions") + } + } + + private fun onAlbumActionChosen(@IdRes itemId: Int): Boolean { + return when (itemId) { + // action to rename album + R.id.action_rename_file -> { + CreateAlbumDialogFragment.newInstance(albumName) + .show( + requireActivity().supportFragmentManager, + CreateAlbumDialogFragment.TAG + ) + true + } + + // action to delete album + R.id.action_delete -> { + showConfirmationDialog(true, null) + true + } + + else -> false + } + } + + private fun setupContent() { + val layoutManager = GridLayoutManager(requireContext(), 1) + binding.listRoot.layoutManager = layoutManager + } + + private fun setupContainingList() { + SwipeRefreshThemeUtils.themeSwipeRefreshLayout(requireContext(), binding.swipeContainingList) + binding.swipeContainingList.setOnRefreshListener { + binding.swipeContainingList.isRefreshing = true + refreshData() + } + } + + @VisibleForTesting + fun populateList(albums: List) { + // exit action mode on data refresh + mMultiChoiceModeListener?.exitSelectionMode() + + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + } + initializeAdapter() + adapter?.showAlbumItems(albums) + } + + private fun fetchAndSetData() { + binding.swipeContainingList.isRefreshing = true + mMultiChoiceModeListener?.exitSelectionMode() + initializeAdapter() + updateEmptyView(false) + lifecycleScope.launch(Dispatchers.IO) { + val readAlbumItemsRemoteOperation = + ReadAlbumItemsRemoteOperation(albumName, mContainerActivity?.storageManager) + val result = client?.let { readAlbumItemsRemoteOperation.execute(it) } + val ocFileList = mutableListOf() + + if (result?.isSuccess == true && result.resultData != null) { + mContainerActivity?.storageManager?.deleteVirtuals(VirtualFolderType.ALBUM) + val contentValues = mutableListOf() + albumRemoteFileList = result.resultData.toMutableList() + + for (remoteFile in albumRemoteFileList) { + val ocFile = mContainerActivity?.storageManager?.getFileByLocalId(remoteFile.localId) + ocFile?.let { + ocFileList.add(it) + + val cv = ContentValues() + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, VirtualFolderType.ALBUM.toString()) + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, it.fileId) + + contentValues.add(cv) + } + } + + mContainerActivity?.storageManager?.saveVirtuals(contentValues) + } + withContext(Dispatchers.Main) { + if (result?.isSuccess == true && result.resultData != null) { + if (result.resultData.isEmpty() || ocFileList.isEmpty()) { + updateEmptyView(true) + } + populateList(ocFileList) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + updateEmptyView(true) + } + hideRefreshLayoutLoader() + } + } + } + + private fun hideRefreshLayoutLoader() { + binding.swipeContainingList.isRefreshing = false + } + + private fun initializeClient() { + if (client == null && optionalUser?.isPresent == true) { + try { + val user = optionalUser?.get() + client = clientFactory.create(user) + } catch (e: CreationException) { + Log_OC.e(TAG, "Error initializing client", e) + } + } + } + + private fun initializeAdapter() { + initializeClient() + if (adapter == null) { + adapter = GalleryAdapter( + requireContext(), + accountManager.user, + this, + preferences, + mContainerActivity!!, + viewThemeUtils, + columnSize, + ThumbnailsCacheManager.getThumbnailDimension() + ) + adapter?.setHasStableIds(true) + setUpActionMode() + } + binding.listRoot.adapter = adapter + + lastMediaItemPosition?.let { + binding.listRoot.layoutManager?.scrollToPosition(it) + } + } + + private fun updateEmptyView(isEmpty: Boolean) { + binding.albumEmptyView.emptyViewLayout.visibility = if (isEmpty) View.VISIBLE else View.GONE + binding.listRoot.visibility = if (isEmpty) View.GONE else View.VISIBLE + } + + override fun onResume() { + super.onResume() + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setupToolbar() + (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar -> + viewThemeUtils.files.themeActionBar(requireContext(), actionBar, albumName) + } + (requireActivity() as FileDisplayActivity).showSortListGroup(false) + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + (requireActivity() as FileDisplayActivity).showHideDefaultToolbarDivider(true) + // clear the subtitle while navigating to any other screen from Media screen + (requireActivity() as FileDisplayActivity).clearToolbarSubtitle() + } + } + + override fun onPause() { + super.onPause() + adapter?.cancelAllPendingTasks() + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).showHideDefaultToolbarDivider(false) + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + columnSize = MAX_COLUMN_SIZE_LANDSCAPE + } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + columnSize = MAX_COLUMN_SIZE_PORTRAIT + } + adapter?.changeColumn(columnSize) + adapter?.notifyDataSetChanged() + } + + override fun onDestroyView() { + lastMediaItemPosition = 0 + super.onDestroyView() + } + + override fun getColumnsCount(): Int { + return columnSize + } + + override fun onShareIconClick(file: OCFile?) { + // nothing to do here + } + + override fun showShareDetailView(file: OCFile?) { + // nothing to do here + } + + override fun showActivityDetailView(file: OCFile?) { + // nothing to do here + } + + override fun onOverflowIconClicked(file: OCFile?, view: View?) { + // nothing to do here + } + + override fun onItemClicked(file: OCFile) { + if (adapter?.isMultiSelect() == true) { + toggleItemToCheckedList(file) + } else { + if (PreviewImageFragment.canBePreviewed(file)) { + (mContainerActivity as FileDisplayActivity).startImagePreview( + file, + VirtualFolderType.ALBUM, + !file.isDown + ) + } else if (file.isDown) { + if (canBePreviewed(file)) { + (mContainerActivity as FileDisplayActivity).startMediaPreview(file, 0, true, true, false, true) + } else { + mContainerActivity?.getFileOperationsHelper()?.openFile(file) + } + } else { + if (canBePreviewed(file) && !file.isEncrypted) { + (mContainerActivity as FileDisplayActivity).startMediaPreview(file, 0, true, true, true, true) + } else { + Log_OC.d(TAG, "Couldn't handle item click") + } + } + } + } + + override fun onLongItemClicked(file: OCFile): Boolean { + // Create only once instance of action mode + if (mMultiChoiceModeListener?.mActiveActionMode != null) { + toggleItemToCheckedList(file) + } else { + requireActivity().startActionMode(mMultiChoiceModeListener) + adapter?.addCheckedFile(file) + } + mMultiChoiceModeListener?.updateActionModeFile(file) + return true + } + + /** + * Will toggle a file selection status from the action mode + * + * @param file The concerned OCFile by the selection/deselection + */ + private fun toggleItemToCheckedList(file: OCFile) { + adapter?.run { + if (isCheckedFile(file)) { + removeCheckedFile(file) + } else { + addCheckedFile(file) + } + } + mMultiChoiceModeListener?.updateActionModeFile(file) + } + + override fun isLoading(): Boolean { + return false + } + + override fun onHeaderClicked() { + // nothing to do here + } + + fun onAlbumRenamed(newAlbumName: String) { + albumName = newAlbumName + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).updateActionBarTitleAndHomeButtonByString(albumName) + } + } + + fun onAlbumDeleted() { + requireActivity().supportFragmentManager.popBackStack() + } + + private fun openActionsMenu(filesCount: Int, checkedFiles: Set) { + throttler.run("overflowClick") { + var toHide: MutableList? = ArrayList() + for (file in checkedFiles) { + if (file.isOfflineOperation) { + toHide = ArrayList( + listOf( + R.id.action_favorite, + R.id.action_move_or_copy, + R.id.action_sync_file, + R.id.action_encrypted, + R.id.action_unset_encrypted, + R.id.action_edit, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_set_as_wallpaper + ) + ) + break + } + } + + toHide?.apply { + addAll( + listOf( + R.id.action_move_or_copy, + R.id.action_sync_file, + R.id.action_encrypted, + R.id.action_unset_encrypted, + R.id.action_edit, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_set_as_wallpaper, + R.id.action_send_file, + R.id.action_send_share_file, + R.id.action_see_details, + R.id.action_rename_file, + R.id.action_pin_to_homescreen + ) + ) + } + + val childFragmentManager = childFragmentManager + val actionBottomSheet = newInstance(filesCount, checkedFiles, true, toHide) + .setResultListener( + childFragmentManager, + this + ) { id: Int -> onFileActionChosen(id, checkedFiles) } + if (this.isDialogFragmentReady()) { + actionBottomSheet.show(childFragmentManager, "actions") + } + } + } + + @Suppress("ReturnCount") + private fun onFileActionChosen(@IdRes itemId: Int, checkedFiles: Set): Boolean { + if (checkedFiles.isEmpty()) { + return false + } + + when (itemId) { + R.id.action_remove_file -> { + showConfirmationDialog(false, checkedFiles) + return true + } + + R.id.action_favorite -> { + mContainerActivity?.fileOperationsHelper?.toggleFavoriteFiles(checkedFiles, true) + return true + } + + R.id.action_unset_favorite -> { + mContainerActivity?.fileOperationsHelper?.toggleFavoriteFiles(checkedFiles, false) + return true + } + + R.id.action_open_file_with -> { + // use only first element as this option will only be shown for single file selection + mContainerActivity?.fileOperationsHelper?.openFile(checkedFiles.first()) + return true + } + + R.id.action_stream_media -> { + // use only first element as this option will only be shown for single file selection + mContainerActivity?.fileOperationsHelper?.streamMediaFile(checkedFiles.first()) + return true + } + + R.id.action_select_all_action_menu -> { + selectAllFiles(true) + return true + } + + R.id.action_deselect_all_action_menu -> { + selectAllFiles(false) + return true + } + + else -> return true + } + } + + /** + * De-/select all elements in the current list view. + * + * @param select `true` to select all, `false` to deselect all + */ + @SuppressLint("NotifyDataSetChanged") + private fun selectAllFiles(select: Boolean) { + adapter?.let { + it.selectAll(select) + it.notifyDataSetChanged() + mMultiChoiceModeListener?.invalidateActionMode() + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + fun onMessageEvent(event: FavoriteEvent) { + try { + val user = accountManager.user + val client = clientFactory.create(user) + val toggleFavoriteOperation = ToggleAlbumFavoriteRemoteOperation( + event.shouldFavorite, + event.remotePath + ) + val remoteOperationResult = toggleFavoriteOperation.execute(client) + + if (remoteOperationResult.isSuccess) { + Handler(Looper.getMainLooper()).post { + mMultiChoiceModeListener?.exitSelectionMode() + } + adapter?.markAsFavorite(event.remotePath, event.shouldFavorite) + } + } catch (e: CreationException) { + Log_OC.e(TAG, "Error processing event", e) + } + } + + private fun onRemoveFileOperation(files: Collection) { + lifecycleScope.launch(Dispatchers.IO) { + val removeFailedFiles = mutableListOf() + try { + val user = accountManager.user + val client = clientFactory.create(user) + withContext(Dispatchers.Main) { + showDialog(true) + } + if (files.size == 1) { + val removeAlbumFileRemoteOperation = RemoveAlbumFileRemoteOperation( + getAlbumRemotePathForRemoval(files.first()) + ) + val remoteOperationResult = removeAlbumFileRemoteOperation.execute(client) + + if (!remoteOperationResult.isSuccess) { + withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage( + requireActivity(), + ErrorMessageAdapter.getErrorCauseMessage( + remoteOperationResult, + removeAlbumFileRemoteOperation, + resources + ) + ) + } + } + } else { + for (file in files) { + val removeAlbumFileRemoteOperation = RemoveAlbumFileRemoteOperation( + getAlbumRemotePathForRemoval(file) + ) + val remoteOperationResult = removeAlbumFileRemoteOperation.execute(client) + + if (!remoteOperationResult.isSuccess) { + removeFailedFiles.add(file) + } + } + } + } catch (e: CreationException) { + Log_OC.e(TAG, "Error processing event", e) + } + + Log_OC.d(TAG, "Files removed: ${removeFailedFiles.size}") + + withContext(Dispatchers.Main) { + if (removeFailedFiles.isNotEmpty()) { + DisplayUtils.showSnackMessage( + requireActivity(), + requireContext().resources.getString(R.string.album_delete_failed_message) + ) + } + showDialog(false) + + // refresh data + refreshData() + } + } + } + + // NMC-4816 fix + // since after files data are fetched in media the file remote path will be actual instead of Albums prefixed + // to remove the file properly form the albums we have to provide the correct album path + private fun getAlbumRemotePathForRemoval(ocFile: OCFile): String { + if (!ocFile.remotePath.startsWith("/albums/$albumName")) { + return albumRemoteFileList.find { it.etag == ocFile.etag || it.etag == ocFile.etagOnServer }?.remotePath + ?: ocFile.remotePath + } + return ocFile.remotePath + } + + private fun showConfirmationDialog(isAlbum: Boolean, files: Collection?) { + val messagePair = getConfirmationDialogMessage(isAlbum, files) + val errorDialog = ConfirmationDialogFragment.newInstance( + messageResId = messagePair.first, + messageArguments = arrayOf(messagePair.second), + titleResId = -1, + positiveButtonTextId = R.string.file_delete, + negativeButtonTextId = R.string.file_keep, + neutralButtonTextId = -1 + ) + errorDialog.setCancelable(false) + errorDialog.setOnConfirmationListener( + object : ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) { + if (isAlbum) { + mContainerActivity?.getFileOperationsHelper()?.removeAlbum(albumName) + } else { + files?.let { + onRemoveFileOperation(it) + } + } + } + + override fun onNeutral(callerTag: String?) { + // not used at the moment + } + + override fun onCancel(callerTag: String?) { + // not used at the moment + } + } + ) + errorDialog.show(requireActivity().supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) + } + + private fun getConfirmationDialogMessage(isAlbum: Boolean, files: Collection?): Pair { + if (isAlbum) { + return Pair(R.string.confirmation_remove_folder_alert, albumName) + } + + return if (files?.size == SINGLE_SELECTION) { + Pair(R.string.confirmation_remove_file_alert, files.first().fileName) + } else { + Pair(R.string.confirmation_remove_files_alert, null) + } + } + + private fun showDialog(isShow: Boolean) { + if (requireActivity() is FileDisplayActivity) { + if (isShow) { + (requireActivity() as FileDisplayActivity).showLoadingDialog( + requireContext().resources.getString( + R.string.wait_a_moment + ) + ) + } else { + (requireActivity() as FileDisplayActivity).dismissLoadingDialog() + } + } + } + + override fun onStart() { + super.onStart() + EventBus.getDefault().register(this) + } + + override fun onStop() { + EventBus.getDefault().unregister(this) + super.onStop() + } + + /** + * Handler for multiple selection mode. + * + * + * Manages input from the user when one or more files or folders are selected in the list. + * + * + * Also listens to changes in navigation drawer to hide and recover multiple selection when it's opened and closed. + */ + internal class MultiChoiceModeListener( + val activity: FragmentActivity, + val adapter: GalleryAdapter?, + val viewThemeUtils: ViewThemeUtils, + val openActionsMenu: (Int, Set) -> Unit + ) : AbsListView.MultiChoiceModeListener, DrawerLayout.DrawerListener { + + var mActiveActionMode: ActionMode? = null + private var mIsActionModeNew = false + + /** + * True when action mode is finished because the drawer was opened + */ + private var mActionModeClosedByDrawer = false + + /** + * Selected items in list when action mode is closed by drawer + */ + private val mSelectionWhenActionModeClosedByDrawer: MutableSet = HashSet() + + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + // nothing to do + } + + override fun onDrawerOpened(drawerView: View) { + // nothing to do + } + + /** + * When the navigation drawer is closed, action mode is recovered in the same state as was when the drawer was + * (started to be) opened. + * + * @param drawerView Navigation drawer just closed. + */ + override fun onDrawerClosed(drawerView: View) { + if (mActionModeClosedByDrawer && mSelectionWhenActionModeClosedByDrawer.isNotEmpty()) { + activity.startActionMode(this) + + adapter?.setCheckedItem(mSelectionWhenActionModeClosedByDrawer) + + mActiveActionMode?.invalidate() + + mSelectionWhenActionModeClosedByDrawer.clear() + } + } + + /** + * If the action mode is active when the navigation drawer starts to move, the action mode is closed and the + * selection stored to be recovered when the drawer is closed. + * + * @param newState One of STATE_IDLE, STATE_DRAGGING or STATE_SETTLING. + */ + override fun onDrawerStateChanged(newState: Int) { + if (DrawerLayout.STATE_DRAGGING == newState && mActiveActionMode != null) { + adapter?.let { + mSelectionWhenActionModeClosedByDrawer.addAll( + it.getCheckedItems() + ) + } + + mActiveActionMode?.finish() + mActionModeClosedByDrawer = true + } + } + + /** + * Update action mode bar when an item is selected / unselected in the list + */ + override fun onItemCheckedStateChanged(mode: ActionMode, position: Int, id: Long, checked: Boolean) { + // nothing to do here + } + + /** + * Load menu and customize UI when action mode is started. + */ + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mActiveActionMode = mode + // Determine if actionMode is "new" or not (already affected by item-selection) + mIsActionModeNew = true + + // fake menu to be able to use bottom sheet instead + val inflater: MenuInflater = activity.menuInflater + inflater.inflate(R.menu.custom_menu_placeholder, menu) + val item = menu.findItem(R.id.custom_menu_placeholder_item) + item.icon?.let { + item.setIcon( + viewThemeUtils.platform.colorDrawable( + it, + ContextCompat.getColor(activity, R.color.fontAppbar) + ) + ) + } + + mode.invalidate() + + // set actionMode color + viewThemeUtils.platform.colorStatusBar( + activity, + ContextCompat.getColor(activity, R.color.action_mode_background) + ) + + adapter?.setMultiSelect(true) + return true + } + + /** + * Updates available action in menu depending on current selection. + */ + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val checkedFiles: Set = adapter?.getCheckedItems() ?: emptySet() + val checkedCount = checkedFiles.size + val title: String = + activity.resources.getQuantityString(R.plurals.items_selected_count, checkedCount, checkedCount) + mode.title = title + + // Determine if we need to finish the action mode because there are no items selected + if (checkedCount == 0 && !mIsActionModeNew) { + exitSelectionMode() + } + + return true + } + + /** + * Exits the multi file selection mode. + */ + fun exitSelectionMode() { + mActiveActionMode?.run { + finish() + } + } + + /** + * Will update (invalidate) the action mode adapter/mode to refresh an item selection change + * + * @param file The concerned OCFile to refresh in adapter + */ + fun updateActionModeFile(file: OCFile) { + mIsActionModeNew = false + mActiveActionMode?.let { + it.invalidate() + adapter?.notifyItemChanged(file) + } + } + + fun invalidateActionMode() { + mActiveActionMode?.invalidate() + } + + /** + * Starts the corresponding action when a menu item is tapped by the user. + */ + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + adapter?.let { + val checkedFiles: Set = it.getCheckedItems() + if (item.itemId == R.id.custom_menu_placeholder_item) { + openActionsMenu(it.getFilesCount(), checkedFiles) + } + return true + } + return false + } + + /** + * Restores UI. + */ + override fun onDestroyActionMode(mode: ActionMode) { + mActiveActionMode = null + + viewThemeUtils.platform.resetStatusBar(activity) + + adapter?.setMultiSelect(false) + adapter?.clearCheckedItems() + } + } + + private val activityResult: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { intentResult: ActivityResult -> + if (Activity.RESULT_OK == intentResult.resultCode) { + intentResult.data?.let { + val paths = it.getStringArrayListExtra(AlbumsPickerActivity.EXTRA_MEDIA_FILES_PATH) + paths?.let { p -> + addMediaToAlbum(p.toMutableList()) + } + } + } + } + + private fun openGalleryToAddMedia() { + activityResult.launch(intentForPickingMediaFiles(requireActivity())) + } + + private fun addMediaToAlbum(filePaths: MutableList) { + viewLifecycleOwner.lifecycleScope.launch { + // short delay to let other transactions finish + // else showLoadingDialog will throw exception + delay(SLEEP_DELAY) + mContainerActivity?.fileOperationsHelper?.albumCopyFiles(filePaths, albumName) + } + } + + fun refreshData() { + refreshFlow.tryEmit(Unit) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (data != null && + requestCode == REQUEST_CODE__SELECT_MEDIA_FROM_APPS && resultCode == RESULT_OK + ) { + requestUploadOfContentFromApps(data) + } + super.onActivityResult(requestCode, resultCode, data) + } + + // method referenced from FileDisplayActivity#requestUploadOfContentFromApps + private fun requestUploadOfContentFromApps(contentIntent: Intent) { + val clipData = contentIntent.clipData + val uris = mutableListOf() + + if (clipData != null) { + for (i in 0 until clipData.itemCount) { + uris.add(clipData.getItemAt(i).uri) + } + } else { + contentIntent.data?.let { uris.add(it) } + } + + // only accept images and videos mime type + val validUris = uris.filter { uri -> + val type = requireActivity().contentResolver.getType(uri) + type?.startsWith("image/") == true || type?.startsWith("video/") == true + } + + if (validUris.isEmpty()) { + DisplayUtils.showSnackMessage(requireActivity(), R.string.album_unsupported_file) + return + } + + val streamsToUpload = ArrayList() + streamsToUpload.addAll(validUris) + + // albums remote path for uploading + val remotePath = + "${resources.getString(R.string.instant_upload_path)}/${resources.getString(R.string.drawer_item_album)}/" + + if (requireActivity() is FileDisplayActivity) { + val uploader = UriUploader( + requireActivity() as FileDisplayActivity, + streamsToUpload, + remotePath, + albumName, + (requireActivity() as FileDisplayActivity).user.orElseThrow( + Supplier { RuntimeException() } + ), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + false, // Not show waiting dialog while file is being copied from private storage + null // Not needed copy temp task listener + ) + + uploader.uploadUris() + } + } + + companion object { + val TAG: String = AlbumItemsFragment::class.java.simpleName + + const val REQUEST_CODE__SELECT_MEDIA_FROM_APPS: Int = REQUEST_CODE__LAST_SHARED + 10 + + private const val SINGLE_SELECTION = 1 + + private const val ARG_ALBUM_NAME = "album_name" + private const val ARG_IS_NEW_ALBUM = "is_new_album" + var lastMediaItemPosition: Int? = null + + private const val MAX_COLUMN_SIZE_LANDSCAPE: Int = 5 + private const val MAX_COLUMN_SIZE_PORTRAIT: Int = 2 + + private const val SLEEP_DELAY = 100L + + fun newInstance(albumName: String, isNewAlbum: Boolean = false): AlbumItemsFragment { + val args = Bundle() + + val fragment = AlbumItemsFragment() + fragment.arguments = args + args.putString(ARG_ALBUM_NAME, albumName) + args.putBoolean(ARG_IS_NEW_ALBUM, isNewAlbum) + return fragment + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt new file mode 100644 index 000000000000..352976f2062c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt @@ -0,0 +1,401 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.albums + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.os.Parcelable +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.bumptech.glide.Glide +import com.google.android.material.appbar.AppBarLayout +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.Throttler +import com.nmc.android.utils.SwipeRefreshThemeUtils +import com.owncloud.android.R +import com.owncloud.android.databinding.AlbumsFragmentBinding +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.albums.PhotoAlbumEntry +import com.owncloud.android.operations.albums.ReadAlbumsRemoteOperation +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.adapter.albums.AlbumFragmentInterface +import com.owncloud.android.ui.adapter.albums.AlbumsAdapter +import com.owncloud.android.ui.decoration.MediaGridItemDecoration +import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Optional +import javax.inject.Inject + +class AlbumsFragment : Fragment(), AlbumFragmentInterface, Injectable { + + private var adapter: AlbumsAdapter? = null + private var client: OwnCloudClient? = null + private var optionalUser: Optional? = null + + private lateinit var binding: AlbumsFragmentBinding + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var throttler: Throttler + + private var mContainerActivity: FileFragment.ContainerActivity? = null + + private var isGridView = true + private var maxColumnSize = 2 + private var isSelectionMode = false + private var listState: Parcelable? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + try { + mContainerActivity = context as FileFragment.ContainerActivity + } catch (e: ClassCastException) { + throw IllegalArgumentException( + context.toString() + " must implement " + + FileFragment.ContainerActivity::class.java.simpleName, + e + ) + } + arguments?.let { + isSelectionMode = it.getBoolean(ARG_IS_SELECTION_MODE, false) + if (isSelectionMode) { + isGridView = false + } + } + } + + override fun onDetach() { + mContainerActivity = null + super.onDetach() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + maxColumnSize = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + MAX_COLUMN_SIZE_LANDSCAPE + } else { + MAX_COLUMN_SIZE_PORTRAIT + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = AlbumsFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + optionalUser = Optional.of(accountManager.user) + showAppBar() + createMenu() + setupContainingList() + setupContent() + setUpEmptyView() + } + + // NMC-4790 fix + private fun showAppBar() { + if (requireActivity() is FileDisplayActivity) { + val appBarLayout = requireActivity().findViewById(R.id.appbar) + appBarLayout?.setExpanded(true, false) + } + } + + private fun setUpEmptyView() { + Glide.with(requireContext()).load(R.drawable.bg_image_albums) + .into(binding.albumEmptyView.albumsBgImage) + + binding.albumEmptyView.createAlbum.setOnClickListener { + showCreateAlbumDialog() + } + } + + private fun createMenu() { + val menuHost: MenuHost = requireActivity() + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.clear() // important: clears any existing activity menu + menuInflater.inflate(R.menu.fragment_create_album, menu) + + val addItem = menu.findItem(R.id.action_create_new_album) + val coloredTitle = SpannableString(addItem.title).apply { + setSpan( + ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.primary)), + 0, + length, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ) + } + addItem.title = coloredTitle + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_create_new_album -> { + showCreateAlbumDialog() + true + } + + else -> false + } + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private fun showCreateAlbumDialog() { + throttler.run("onCreateAlbumClick") { + val fragment = requireActivity().supportFragmentManager.findFragmentByTag(CreateAlbumDialogFragment.TAG) + if (fragment == null) { + CreateAlbumDialogFragment.newInstance() + .show( + requireActivity().supportFragmentManager, + CreateAlbumDialogFragment.TAG + ) + } + } + } + + private fun setupContent() { + binding.listRoot.setHasFixedSize(true) + if (isGridView) { + val layoutManager = GridLayoutManager(requireContext(), maxColumnSize) + binding.listRoot.layoutManager = layoutManager + binding.listRoot.addItemDecoration(MediaGridItemDecoration(resources.getDimensionPixelSize(R.dimen.album_grid_spacing))) + val padding = resources.getDimensionPixelSize(R.dimen.album_recycler_view_grid_padding) + binding.listRoot.setPadding(padding, padding, padding, padding) + } else { + val layoutManager = LinearLayoutManager(requireContext()) + binding.listRoot.layoutManager = layoutManager + } + fetchAndSetData() + } + + private fun setupContainingList() { + SwipeRefreshThemeUtils.themeSwipeRefreshLayout(requireContext(), binding.swipeContainingList) + binding.swipeContainingList.setOnRefreshListener { + fetchAndSetData() + } + } + + @VisibleForTesting + fun populateList(albums: List?) { + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + } + initializeAdapter() + adapter?.setAlbumItems(albums) + } + + private fun fetchAndSetData() { + binding.swipeContainingList.isRefreshing = true + initializeAdapter() + updateEmptyView(false) + lifecycleScope.launch(Dispatchers.IO) { + val albumsRemoteOperation = ReadAlbumsRemoteOperation() + val result = client?.let { albumsRemoteOperation.execute(it) } + withContext(Dispatchers.Main) { + if (result?.isSuccess == true && result.resultData != null) { + if (result.resultData.isEmpty()) { + updateEmptyView(true) + } + populateList(result.resultData) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + updateEmptyView(true) + } + hideRefreshLayoutLoader() + } + } + } + + private fun hideRefreshLayoutLoader() { + binding.swipeContainingList.isRefreshing = false + } + + private fun initializeClient() { + if (client == null && optionalUser?.isPresent == true) { + try { + val user = optionalUser?.get() + client = clientFactory.create(user) + } catch (e: CreationException) { + Log_OC.e(TAG, "Error initializing client", e) + } + } + } + + private fun initializeAdapter() { + initializeClient() + if (adapter == null) { + adapter = AlbumsAdapter( + requireContext(), + mContainerActivity?.storageManager, + accountManager.user, + this, + syncedFolderProvider, + preferences, + viewThemeUtils, + isGridView + ) + } + binding.listRoot.adapter = adapter + + // Restore scroll state + listState?.let { + binding.listRoot.layoutManager?.onRestoreInstanceState(it) + } + } + + private fun updateEmptyView(isEmpty: Boolean) { + binding.albumEmptyView.emptyViewLayout.visibility = if (isEmpty) View.VISIBLE else View.GONE + binding.listRoot.visibility = if (isEmpty) View.GONE else View.VISIBLE + } + + override fun onResume() { + super.onResume() + if (isSelectionMode) { + binding.root.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.bg_default, null)) + } + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setupToolbar() + (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar -> + viewThemeUtils.files.themeActionBar( + requireContext(), + actionBar, + R.string.drawer_item_album, + isMenu = true + ) + } + (requireActivity() as FileDisplayActivity).showSortListGroup(false) + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + (requireActivity() as FileDisplayActivity).showHideDefaultToolbarDivider(true) + // clear the subtitle while navigating to any other screen from Media screen + (requireActivity() as FileDisplayActivity).clearToolbarSubtitle() + } + } + + fun navigateToAlbumItemsFragment(albumName: String, isNewAlbum: Boolean = false) { + requireActivity().supportFragmentManager.beginTransaction().apply { + addToBackStack(null) + replace( + R.id.left_fragment_container, + AlbumItemsFragment.newInstance(albumName, isNewAlbum = isNewAlbum), + AlbumItemsFragment.TAG + ) + commit() + } + } + + fun refreshAlbums() { + fetchAndSetData() + } + + override fun onPause() { + super.onPause() + adapter?.cancelAllPendingTasks() + listState = binding.listRoot.layoutManager?.onSaveInstanceState() + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).showHideDefaultToolbarDivider(false) + } + } + + private val isGridEnabled: Boolean + get() { + return binding.listRoot.layoutManager is GridLayoutManager + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (isGridEnabled) { + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + maxColumnSize = MAX_COLUMN_SIZE_LANDSCAPE + } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + maxColumnSize = MAX_COLUMN_SIZE_PORTRAIT + } + (binding.listRoot.layoutManager as GridLayoutManager).setSpanCount(maxColumnSize) + } + } + + companion object { + val TAG: String = AlbumsFragment::class.java.simpleName + private const val ARG_IS_SELECTION_MODE = "is_selection_mode" + const val ARG_SELECTED_ALBUM_NAME = "selected_album_name" + + private const val MAX_COLUMN_SIZE_LANDSCAPE: Int = 4 + private const val MAX_COLUMN_SIZE_PORTRAIT: Int = 2 + + fun newInstance(isSelectionMode: Boolean = false): AlbumsFragment { + val args = Bundle() + args.putBoolean(ARG_IS_SELECTION_MODE, isSelectionMode) + val fragment = AlbumsFragment() + fragment.arguments = args + return fragment + } + } + + override fun onItemClick(album: PhotoAlbumEntry) { + if (isSelectionMode) { + val resultIntent = Intent().apply { + putExtra(ARG_SELECTED_ALBUM_NAME, album.albumName) + } + requireActivity().setResult(Activity.RESULT_OK, resultIntent) + requireActivity().finish() + return + } + navigateToAlbumItemsFragment(album.albumName) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index b071a92a9eff..0395f592b62c 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -1039,6 +1039,55 @@ public void moveOrCopyFiles(String action, final List filePaths, final O fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); } + public void createAlbum(String albumName) { + // Create Album + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_CREATE_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_ALBUM_NAME, albumName); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + + public void albumCopyFiles(final List filePaths, final String targetFolder) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + + for (String path : filePaths) { + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_ALBUM_COPY_FILE); + service.putExtra(OperationsService.EXTRA_NEW_PARENT_PATH, targetFolder); + service.putExtra(OperationsService.EXTRA_REMOTE_PATH, path); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + } + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + + public void renameAlbum(String oldAlbumName, String newAlbumName) { + Intent service = new Intent(fileActivity, OperationsService.class); + + service.setAction(OperationsService.ACTION_RENAME_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_REMOTE_PATH, oldAlbumName); + service.putExtra(OperationsService.EXTRA_NEWNAME, newAlbumName); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + + public void removeAlbum(String albumName) { + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_REMOVE_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_ALBUM_NAME, albumName); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + public void exportFiles(Collection files, Context context, View view, diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt index 9f08ad99bbce..f9a5d2479c58 100644 --- a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt +++ b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt @@ -51,6 +51,23 @@ class UriUploader( private val mShowWaitingDialog: Boolean, private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener? ) { + // NMC Customization + // used when uploading from Albums + var albumName: String? = null + + constructor( + activity: FileActivity, + urisToUpload: List, + uploadPath: String, + albumName: String?, + user: User, + behaviour: Int, + showWaitingDialog: Boolean, + copyTmpTaskListener: OnCopyTmpFilesTaskListener? + ) : this(activity, urisToUpload, uploadPath, + user, behaviour, showWaitingDialog, copyTmpTaskListener){ + this.albumName = albumName + } enum class UriUploaderResultCode { OK, @@ -132,18 +149,34 @@ class UriUploader( * @param remotePaths Absolute paths in the current OC account to set to the uploaded file. */ private fun requestUpload(localPaths: Array, remotePaths: Array) { - FileUploadHelper.instance().uploadNewFiles( - user, - localPaths, - remotePaths, - mBehaviour, - // do not create parent folder if not existent - false, - UploadFileOperation.CREATED_BY_USER, - requiresWifi = false, - requiresCharging = false, - nameCollisionPolicy = NameCollisionPolicy.ASK_USER - ) + if (albumName.isNullOrEmpty()) { + FileUploadHelper.instance().uploadNewFiles( + user, + localPaths, + remotePaths, + mBehaviour, + // do not create parent folder if not existent + false, + UploadFileOperation.CREATED_BY_USER, + requiresWifi = false, + requiresCharging = false, + nameCollisionPolicy = NameCollisionPolicy.ASK_USER + ) + } else { + FileUploadHelper.instance().uploadAndCopyNewFilesForAlbum( + user, + localPaths, + remotePaths, + albumName!!, + mBehaviour, + // create parent folder if not existent + true, + UploadFileOperation.CREATED_BY_USER, + requiresWifi = false, + requiresCharging = false, + nameCollisionPolicy = NameCollisionPolicy.RENAME // use RENAME policy to make sure all files are uploaded + ) + } } /** @@ -155,7 +188,7 @@ class UriUploader( if (mShowWaitingDialog) { mActivity.showLoadingDialog(mActivity.resources.getString(R.string.wait_for_tmp_copy_from_private_storage)) } - val copyTask = CopyAndUploadContentUrisTask(mCopyTmpTaskListener, mActivity) + val copyTask = CopyAndUploadContentUrisTask(mCopyTmpTaskListener, mActivity, albumName) val fm = mActivity.supportFragmentManager // Init Fragment without UI to retain AsyncTask across configuration changes diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 9b16124a0e6d..e338097fc82c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -39,6 +39,7 @@ import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVGParseException import com.github.chrisbanes.photoview.PhotoView @@ -59,6 +60,7 @@ import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.ThumbnailsCacheManager.AsyncResizedImageDrawable import com.owncloud.android.datamodel.ThumbnailsCacheManager.ResizedImageGenerationTask import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment @@ -69,6 +71,9 @@ import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import pl.droidsonroids.gif.GifDrawable import java.io.FileInputStream import java.io.FileNotFoundException @@ -366,17 +371,8 @@ class PreviewImageFragment : override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.custom_menu_placeholder_item -> { - val file = file - if (containerActivity.storageManager != null && file != null) { - // Update the file - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - setFile(updatedFile) - - val fileNew = getFile() - if (fileNew != null) { - showFileActions(file) - } - } + // NMC Customization for Albums + onOverflowClick() true } @@ -388,6 +384,60 @@ class PreviewImageFragment : ) } + /** + * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails + */ + private fun onOverflowClick(isManualClick: Boolean = false) { + val file = file + if (containerActivity.storageManager != null && file != null) { + // Update the file + val updatedFile = containerActivity.storageManager.getFileById(file.fileId) + // check for albums file + // for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + } else { + setFile(updatedFile) + + val fileNew = getFile() + if (fileNew != null) { + showFileActions(file) + } + } + } + } + + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).showLoadingDialog(getString(R.string.wait_a_moment)) + } + lifecycleScope.launch(Dispatchers.IO) { + val fetchRemoteFileOperation = + FetchRemoteFileOperation( + requireActivity(), + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = containerActivity.storageManager, + ) + val result = fetchRemoteFileOperation.execute(requireActivity()) + withContext(Dispatchers.Main) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).dismissLoadingDialog() + } + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(requireContext())) + } + } + } + } + private fun showFileActions(file: OCFile) { val additionalFilter = FileAction.getFilePreviewActions(getFile()) val fragmentManager = childFragmentManager diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt index 9f75a6972e05..dc1901c30782 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt @@ -97,6 +97,9 @@ class PreviewImagePagerAdapter : FragmentStateAdapter { if (type == VirtualFolderType.GALLERY) { imageFiles = mStorageManager.allGalleryItems imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) + } else if (type == VirtualFolderType.ALBUM) { + imageFiles = mStorageManager.getVirtualFolderContent(type, false) + imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) } else { imageFiles = mStorageManager.getVirtualFolderContent(type, true) } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt index 6ed9b4297b0f..acee9100894b 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt @@ -85,6 +85,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.DownloadType +import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.SynchronizeFileOperation import com.owncloud.android.ui.activity.FileActivity @@ -520,17 +521,57 @@ class PreviewMediaActivity : } if (item.itemId == R.id.custom_menu_placeholder_item) { - val file = file + // NMC Customization for Albums + onOverflowClick() + } - if (storageManager != null && file != null) { - val updatedFile = storageManager.getFileById(file.fileId) + return super.onOptionsItemSelected(item) + } + + /** + * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails + */ + private fun onOverflowClick(isManualClick: Boolean = false) { + val file = file + if (storageManager != null && file != null) { + val updatedFile = storageManager.getFileById(file.fileId) + // check for albums file + // for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + } else { setFile(updatedFile) val fileNew = getFile() fileNew?.let { showFileActions(it) } } } + } - return super.onOptionsItemSelected(item) + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + showLoadingDialog(getString(R.string.wait_a_moment)) + lifecycleScope.launch(Dispatchers.IO) { + val fetchRemoteFileOperation = + FetchRemoteFileOperation( + this@PreviewMediaActivity, + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = storageManager, + ) + val result = fetchRemoteFileOperation.execute(this@PreviewMediaActivity) + withContext(Dispatchers.Main) { + dismissLoadingDialog() + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(this@PreviewMediaActivity)) + } + } + } } private fun showFileActions(file: OCFile) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt index de86259e9337..d14385f2f46b 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt @@ -37,6 +37,7 @@ import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -65,12 +66,17 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.files.StreamMediaFileOperation import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.ui.activity.DrawerActivity import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.lang.ref.WeakReference import java.util.concurrent.Executors import javax.inject.Inject @@ -318,13 +324,8 @@ class PreviewMediaFragment : return when (menuItem.itemId) { R.id.custom_menu_placeholder_item -> { if (containerActivity.storageManager == null || file == null) return false - - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - file = updatedFile - file?.let { newFile -> - showFileActions(newFile) - } - + // NMC Customization for Albums + onOverflowClick() true } @@ -337,6 +338,54 @@ class PreviewMediaFragment : ) } + /** + * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails + */ + private fun onOverflowClick(isManualClick: Boolean = false) { + val updatedFile = containerActivity.storageManager.getFileById(file.fileId) + // check for albums file + // for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + } else { + file = updatedFile + file?.let { newFile -> + showFileActions(newFile) + } + } + } + + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).showLoadingDialog(getString(R.string.wait_a_moment)) + } + lifecycleScope.launch(Dispatchers.IO) { + val fetchRemoteFileOperation = + FetchRemoteFileOperation( + requireActivity(), + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = containerActivity.storageManager, + ) + val result = fetchRemoteFileOperation.execute(requireActivity()) + withContext(Dispatchers.Main) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).dismissLoadingDialog() + } + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(requireContext())) + } + } + } + } + private fun showFileActions(file: OCFile) { val additionalFilter = FileAction.getFilePreviewActions(getFile()) newInstance(file, false, additionalFilter) diff --git a/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java b/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java index 6795feb714e6..e674adfb425f 100644 --- a/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java +++ b/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java @@ -30,6 +30,8 @@ import com.owncloud.android.operations.UpdateSharePermissionsOperation; import com.owncloud.android.operations.UpdateShareViaLinkOperation; import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.operations.albums.CopyFileToAlbumOperation; +import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation; import org.apache.commons.httpclient.ConnectTimeoutException; @@ -148,6 +150,10 @@ String getMessageForResultAndOperation( } else if (operation instanceof CopyFileOperation) { message = getMessageForCopyFileOperation(result, res); + } else if (operation instanceof CopyFileToAlbumOperation) { + message = getMessageForCopyFileToAlbumOperation(result, res); + } else if (operation instanceof RenameAlbumRemoteOperation) { + message = getMessageForRenameAlbumOperation(result, res); } return message; @@ -500,4 +506,21 @@ String getMessageForOperation(RemoteOperation operation, Resources res) { return message; } + + private static @Nullable + String getMessageForCopyFileToAlbumOperation(RemoteOperationResult result, Resources res) { + // NMC-4948 fix + if (result.getCode() == ResultCode.CONFLICT) { + return res.getString(R.string.album_copy_file_conflict); + } + return null; + } + + private static @Nullable + String getMessageForRenameAlbumOperation(RemoteOperationResult result, Resources res) { + if (result.getCode() == ResultCode.INVALID_OVERWRITE) { + return res.getString(R.string.album_rename_conflict); + } + return null; + } } diff --git a/app/src/main/res/drawable-xxxhdpi/bg_image_albums.png b/app/src/main/res/drawable-xxxhdpi/bg_image_albums.png new file mode 100644 index 000000000000..a387dc95fe5a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/bg_image_albums.png differ diff --git a/app/src/main/res/drawable/album_no_photo_placeholder.xml b/app/src/main/res/drawable/album_no_photo_placeholder.xml new file mode 100644 index 000000000000..4b31ce242b9a --- /dev/null +++ b/app/src/main/res/drawable/album_no_photo_placeholder.xml @@ -0,0 +1,564 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/empty_album_detailed_view.xml b/app/src/main/res/drawable/empty_album_detailed_view.xml new file mode 100644 index 000000000000..1ebcd208a454 --- /dev/null +++ b/app/src/main/res/drawable/empty_album_detailed_view.xml @@ -0,0 +1,568 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/nav_albums.xml b/app/src/main/res/drawable/nav_albums.xml new file mode 100644 index 000000000000..64c7d5aec430 --- /dev/null +++ b/app/src/main/res/drawable/nav_albums.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/layout/albums_empty_view.xml b/app/src/main/res/layout/albums_empty_view.xml new file mode 100644 index 000000000000..75d8c0112bcd --- /dev/null +++ b/app/src/main/res/layout/albums_empty_view.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/albums_fragment.xml b/app/src/main/res/layout/albums_fragment.xml new file mode 100644 index 000000000000..d0bd8639df2a --- /dev/null +++ b/app/src/main/res/layout/albums_fragment.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/albums_grid_item.xml b/app/src/main/res/layout/albums_grid_item.xml new file mode 100644 index 000000000000..b6a83c99b121 --- /dev/null +++ b/app/src/main/res/layout/albums_grid_item.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/albums_list_item.xml b/app/src/main/res/layout/albums_list_item.xml new file mode 100644 index 000000000000..2257ceb4e1bf --- /dev/null +++ b/app/src/main/res/layout/albums_list_item.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/files_folder_picker.xml b/app/src/main/res/layout/files_folder_picker.xml index f1d4f53ebb5d..095ab09641fe 100644 --- a/app/src/main/res/layout/files_folder_picker.xml +++ b/app/src/main/res/layout/files_folder_picker.xml @@ -25,6 +25,7 @@ android:layout_weight="1" /> @@ -37,6 +38,7 @@ + + diff --git a/app/src/main/res/menu/custom_menu_placeholder.xml b/app/src/main/res/menu/custom_menu_placeholder.xml index f84383a573de..ae1973a3e875 100644 --- a/app/src/main/res/menu/custom_menu_placeholder.xml +++ b/app/src/main/res/menu/custom_menu_placeholder.xml @@ -8,6 +8,11 @@ --> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_create_album.xml b/app/src/main/res/menu/fragment_create_album.xml new file mode 100644 index 000000000000..dd9b52acc789 --- /dev/null +++ b/app/src/main/res/menu/fragment_create_album.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/partial_drawer_entries.xml b/app/src/main/res/menu/partial_drawer_entries.xml index 8cb3bb93b88f..fe9de8f8cfc4 100644 --- a/app/src/main/res/menu/partial_drawer_entries.xml +++ b/app/src/main/res/menu/partial_drawer_entries.xml @@ -33,6 +33,11 @@ android:icon="@drawable/selector_favorites" android:orderInCategory="0" android:title="@string/drawer_item_favorites" /> + + + + Alben + Album erstellen + Neues Album + Album umbenennen + Speichern + Gib einen Namen für das Album ein + Der Albumname darf nicht leer sein + Der Albumname darf nicht mit einem ungültigen Zeichen beginnen + Mehr hinzufügen + Album umbenennen + Album löschen + Einige Dateien konnten nicht gelöscht werden. + Das Album existiert bereits + Album auswählen + Mediendateien auswählen + Erstelle Alben für deine Fotos + Sie können all Ihre Fotos in beliebig vielen Alben organisieren. Bisher haben Sie noch kein Album erstellt. + Zum Album hinzufügen + Datei erfolgreich hinzugefügt + Es fehlen nur noch Ihre Fotos + Sie können so viele Fotos hinzufügen, wie Sie möchten. Ein Foto kann auch mehreren Alben zugeordnet werden. + Fotos hinzufügen + %d Elemente — %s + Nicht unterstützte Medien + Dateien hochladen + Dateien auswählen + Dieser Name wird bereits verwendet. + Existiert bereits. + \ No newline at end of file diff --git a/app/src/main/res/values/nmc_album_dims.xml b/app/src/main/res/values/nmc_album_dims.xml new file mode 100644 index 000000000000..50e11e3489f7 --- /dev/null +++ b/app/src/main/res/values/nmc_album_dims.xml @@ -0,0 +1,16 @@ + + + + 78dp + 56dp + 140dp + 8dp + 4dp + 4dp + 8dp + \ No newline at end of file diff --git a/app/src/main/res/values/nmc_album_strings.xml b/app/src/main/res/values/nmc_album_strings.xml new file mode 100644 index 000000000000..a9dcf31bc541 --- /dev/null +++ b/app/src/main/res/values/nmc_album_strings.xml @@ -0,0 +1,37 @@ + + + + Albums + Create album + New album + Rename album + Rename + Enter your new Album name + Album name cannot be empty + Album name cannot start with invalid char + Add more + Rename Album + Delete Album + Failed to delete few of the files. + Album already exists + Pick Album + Pick Media Files + Create Albums for your Photos + You can organize all your photos in as many albums as you like. You haven\'t created an album yet. + Add to Album + File added successfully + All that\'s missing are your photos + You can add as many photos as you like. A photo can also belong to more than one album. + Add photos + %d Items — %s + Unsupported media + Upload from cameraroll + Select images from account + This name is already in use. + Already exists. + \ No newline at end of file