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