diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e58ae55fb6b4..c2b8ceee503c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -269,6 +269,9 @@ android:exported="false" tools:replace="android:exported" /> + diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt index a95a35b30fea..68b54c8b0aa0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt @@ -78,6 +78,10 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi credentialIntent: PendingIntent?, errorMessage: String ) { + if (uploadFileOperation.isMissingPermissionThrown) { + return + } + val textId = getFailedResultTitleId(resultCode) notificationBuilder.run { diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index 834be009e9f2..95f594a057af 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -58,6 +58,8 @@ import com.owncloud.android.operations.e2e.E2EClientData; import com.owncloud.android.operations.e2e.E2EData; import com.owncloud.android.operations.e2e.E2EFiles; +import com.owncloud.android.operations.upload.UploadFileException; +import com.owncloud.android.operations.upload.UploadFileOperationExtensionsKt; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.EncryptionUtilsV2; import com.owncloud.android.utils.FileStorageUtils; @@ -118,6 +120,7 @@ public class UploadFileOperation extends SyncOperation { public static final int CREATED_BY_USER = 0; public static final int CREATED_AS_INSTANT_PICTURE = 1; public static final int CREATED_AS_INSTANT_VIDEO = 2; + public static final int MISSING_FILE_PERMISSION_NOTIFICATION_ID = 2501; /** * OCFile which is to be uploaded. @@ -166,6 +169,7 @@ public class UploadFileOperation extends SyncOperation { private boolean encryptedAncestor; private OCFile duplicatedEncryptedFile; + private AtomicBoolean missingPermissionThrown = new AtomicBoolean(false); public static OCFile obtainNewOCFileToUpload(String remotePath, String localPath, String mimeType) { OCFile newFile = new OCFile(remotePath); @@ -403,9 +407,31 @@ public Context getContext() { return mContext; } + public boolean isMissingPermissionThrown() { + return missingPermissionThrown.get(); + } + @Override @SuppressWarnings("PMD.AvoidDuplicateLiterals") protected RemoteOperationResult run(OwnCloudClient client) { + if (TextUtils.isEmpty(getStoragePath())) { + Log_OC.e(TAG, "Upload cancelled for " + getStoragePath() + ": file path is null or empty."); + return new RemoteOperationResult<>(new UploadFileException.EmptyOrNullFilePath()); + } + + final var localFile = new File(getStoragePath()); + if (!localFile.exists()) { + Log_OC.e(TAG, "Upload cancelled for " + getStoragePath() + ": local file not exists."); + return new RemoteOperationResult<>(ResultCode.LOCAL_FILE_NOT_FOUND); + } + + if (!localFile.canRead()) { + Log_OC.e(TAG, "Upload cancelled for " + getStoragePath() + ": file is not readable or inaccessible."); + UploadFileOperationExtensionsKt.showStoragePermissionNotification(this); + missingPermissionThrown.set(true); + return new RemoteOperationResult<>(new UploadFileException.MissingPermission()); + } + mCancellationRequested.set(false); mUploadStarted.set(true); diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiver.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiver.kt new file mode 100644 index 000000000000..2d35262d0ee1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiver.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.core.content.IntentCompat +import androidx.core.net.toUri +import com.owncloud.android.operations.UploadFileOperation + +class UploadFileBroadcastReceiver : BroadcastReceiver() { + companion object { + const val ACTION_TYPE = "UploadFileBroadcastReceiver.ACTION_TYPE" + } + + override fun onReceive(context: Context, intent: Intent) { + val actionType = + IntentCompat.getSerializableExtra(intent, ACTION_TYPE, UploadFileBroadcastReceiverActions::class.java) + ?: return + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(UploadFileOperation.MISSING_FILE_PERMISSION_NOTIFICATION_ID) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + actionType == UploadFileBroadcastReceiverActions.ALLOW_ALL_FILES + ) { + redirectToAllFilesAccess(context) + } else { + redirectToAppInfo(context) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun redirectToAllFilesAccess(context: Context) { + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = "package:${context.packageName}".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }.run { + context.startActivity(this) + } + } + + private fun redirectToAppInfo(context: Context) { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }.run { + context.startActivity(this) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiverActions.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiverActions.kt new file mode 100644 index 000000000000..c0d00d4279ae --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiverActions.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.upload + +enum class UploadFileBroadcastReceiverActions : java.io.Serializable { + ALLOW_ALL_FILES, + APP_PERMISSIONS +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt new file mode 100644 index 000000000000..880a5ebbfca1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.upload + +sealed class UploadFileException(message: String) : Exception(message) { + class EmptyOrNullFilePath : UploadFileException("Empty or null file path") + class MissingPermission : UploadFileException("Missing storage permission") +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileOperationExtensions.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileOperationExtensions.kt new file mode 100644 index 000000000000..72ba621457bf --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileOperationExtensions.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.owncloud.android.R +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.UploadFileOperation.MISSING_FILE_PERMISSION_NOTIFICATION_ID +import com.owncloud.android.ui.notifications.NotificationUtils + +fun UploadFileOperation.showStoragePermissionNotification() { + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) + ?: return + val alreadyShown = notificationManager.activeNotifications.any { + it.id == MISSING_FILE_PERMISSION_NOTIFICATION_ID + } + if (alreadyShown) { + return + } + + val allowAllFileAccessAction = getAllowAllFileAccessAction(context) + val appPermissionsAction = getAppPermissionsAction(context) + + val notificationBuilder = + NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setContentTitle(context.getString(R.string.upload_missing_storage_permission_title)) + .setContentText(context.getString(R.string.upload_missing_storage_permission_description)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .addAction(allowAllFileAccessAction) + .addAction(appPermissionsAction) + .setAutoCancel(true) + + notificationManager.notify(MISSING_FILE_PERMISSION_NOTIFICATION_ID, notificationBuilder.build()) +} + +private fun getActionPendingIntent(context: Context, actionType: UploadFileBroadcastReceiverActions): PendingIntent { + val intent = Intent(context, UploadFileBroadcastReceiver::class.java).apply { + action = "com.owncloud.android.ACTION_UPLOAD_FILE_PERMISSION" + putExtra(UploadFileBroadcastReceiver.ACTION_TYPE, actionType) + } + + return PendingIntent.getBroadcast( + context, + actionType.ordinal, + intent, + PendingIntent.FLAG_IMMUTABLE + ) +} + +private fun getAllowAllFileAccessAction(context: Context): NotificationCompat.Action { + val pendingIntent = getActionPendingIntent(context, UploadFileBroadcastReceiverActions.ALLOW_ALL_FILES) + return NotificationCompat.Action( + null, + context.getString(R.string.upload_missing_storage_permission_allow_file_access), + pendingIntent + ) +} + +private fun getAppPermissionsAction(context: Context): NotificationCompat.Action { + val pendingIntent = getActionPendingIntent(context, UploadFileBroadcastReceiverActions.APP_PERMISSIONS) + return NotificationCompat.Action( + null, + context.getString(R.string.upload_missing_storage_permission_app_permissions), + pendingIntent + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82258fc8e0e8..5fbe91360b29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1357,4 +1357,8 @@ The device is likely not connected to the internet Please select custom permission Link not followed due to security settings. + Upload Stopped – Storage Permission Required + Your files cannot be uploaded without access to local storage. Tap to grant permission. + Allow all file access + App permissions