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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions opencloudApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
API >= 23; the app needs to handle this
-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!--
Since API 29 the system strips location metadata (GPS EXIF) from photos and videos read
through the media store / SAF unless this permission is held. Requested at runtime only
when picture or video automatic uploads are enabled.
-->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!--
Notifications are off by default since API 33;
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,16 @@ fun Fragment.showAlertDialog(
positiveButtonText: String = getString(android.R.string.ok),
positiveButtonListener: ((DialogInterface, Int) -> Unit)? = null,
negativeButtonText: String = "",
negativeButtonListener: ((DialogInterface, Int) -> Unit)? = null
negativeButtonListener: ((DialogInterface, Int) -> Unit)? = null,
onDismissListener: DialogInterface.OnDismissListener? = null
) {
val requiredActivity = activity ?: return
AlertDialog.Builder(requiredActivity)
.setTitle(title)
.setMessage(message)
.setPositiveButton(positiveButtonText, positiveButtonListener)
.setNegativeButton(negativeButtonText, negativeButtonListener)
.setOnDismissListener(onDismissListener)
.show()
.avoidScreenshotsIfNeeded()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* openCloud Android client application
*
* Copyright (C) 2026 OpenCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package eu.opencloud.android.presentation.settings.automaticuploads

import android.Manifest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import eu.opencloud.android.R
import eu.opencloud.android.data.providers.SharedPreferencesProvider
import eu.opencloud.android.extensions.showMessageInSnackbar
import eu.opencloud.android.utils.MediaLocationUtils
import org.koin.android.ext.android.inject

/**
* Asks for [Manifest.permission.ACCESS_MEDIA_LOCATION] from the picture and video automatic
* upload settings. Without it, Android strips the GPS metadata from the media we read (API >= 29).
*
* The system dialog is only launched when Android will really show it, so the user is never
* prompted on every visit and the "denied" hint only follows an answer the user actually gave.
*
* Must be created while the fragment is being initialized (it registers an activity result launcher).
*/
class MediaLocationPermissionRequester(private val fragment: Fragment) {

private val preferencesProvider: SharedPreferencesProvider by fragment.inject()

private val requestPermissionLauncher =
fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted && fragment.isAdded) {
fragment.showMessageInSnackbar(fragment.getString(R.string.media_location_permission_denied))
}
}

// True between the user switching uploads on and the "Important" dialog being dismissed,
// so the system dialog does not open on top of it.
private var isPostponed = false

/**
* The screen shows enabled automatic uploads: ask, but only if the user was never asked before.
*/
fun requestIfNeverAsked() {
if (isPostponed) return
request(userInitiated = false)
}

/**
* The user is switching automatic uploads on. Holds back [requestIfNeverAsked] until
* [requestAfterEnabling] is called.
*/
fun postpone() {
isPostponed = true
}

/**
* The user switched automatic uploads on and has dismissed the "Important" dialog. Asks again
* after an earlier denial as long as Android still shows the dialog.
*/
fun requestAfterEnabling() {
isPostponed = false
request(userInitiated = true)
}

private fun request(userInitiated: Boolean) {
val context = fragment.context ?: return
if (!fragment.isAdded) return

val shouldRequest = shouldRequest(
isGranted = MediaLocationUtils.isAccessMediaLocationGranted(context),
alreadyRequested = preferencesProvider.getBoolean(PREFERENCE_MEDIA_LOCATION_PERMISSION_REQUESTED, false),
shouldShowRationale = fragment.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_MEDIA_LOCATION),
userInitiated = userInitiated,
)
if (!shouldRequest) return

preferencesProvider.putBoolean(PREFERENCE_MEDIA_LOCATION_PERMISSION_REQUESTED, true)
requestPermissionLauncher.launch(Manifest.permission.ACCESS_MEDIA_LOCATION)
}

companion object {
// Shared by pictures and videos: it is one permission, asked once.
internal const val PREFERENCE_MEDIA_LOCATION_PERMISSION_REQUESTED = "PREFERENCE_MEDIA_LOCATION_PERMISSION_REQUESTED"

/**
* - Never asked: ask.
* - Asked and denied once: Android reports a rationale and still shows the dialog. Ask again
* only when the user just enabled uploads, not on every visit to the screen.
* - Asked and denied for good: Android answers "denied" by itself without showing anything,
* so do not launch at all.
*/
internal fun shouldRequest(
isGranted: Boolean,
alreadyRequested: Boolean,
shouldShowRationale: Boolean,
userInitiated: Boolean,
): Boolean = !isGranted && (!alreadyRequested || (userInitiated && shouldShowRationale))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
picturesViewModel.handleSelectPictureUploadsSourcePath(contentUriForTree)
}

private val mediaLocationPermissionRequester = MediaLocationPermissionRequester(this)

override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_picture_uploads, rootKey)

Expand Down Expand Up @@ -149,6 +151,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration ->
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
mediaLocationPermissionRequester.requestIfNeverAsked()
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
prefPictureUploadsSourcePath?.summary = DisplayUtils.getPathWithoutLastSlash(it.sourcePath.toUri().path)
Expand All @@ -170,10 +173,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
val value = newValue as Boolean

if (value) {
// Ask for the media location permission once this dialog is gone, not on top of it.
mediaLocationPermissionRequester.postpone()
picturesViewModel.enablePictureUploads(selectedAccount)
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.proper_pics_folder_warning_camera_upload)
message = getString(R.string.proper_pics_folder_warning_camera_upload),
onDismissListener = { mediaLocationPermissionRequester.requestAfterEnabling() }
)
true
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
videosViewModel.handleSelectVideoUploadsSourcePath(contentUriForTree)
}

private val mediaLocationPermissionRequester = MediaLocationPermissionRequester(this)

override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_video_uploads, rootKey)

Expand Down Expand Up @@ -147,6 +149,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
videosViewModel.videoUploads.collect { videoUploadsConfiguration ->
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
mediaLocationPermissionRequester.requestIfNeverAsked()
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
prefVideoUploadsSourcePath?.summary = DisplayUtils.getPathWithoutLastSlash(it.sourcePath.toUri().path)
Expand All @@ -168,10 +171,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
val value = newValue as Boolean

if (value) {
// Ask for the media location permission once this dialog is gone, not on top of it.
mediaLocationPermissionRequester.postpone()
videosViewModel.enableVideoUploads(selectedAccount)
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.proper_videos_folder_warning_camera_upload)
message = getString(R.string.proper_videos_folder_warning_camera_upload),
onDismissListener = { mediaLocationPermissionRequester.requestAfterEnabling() }
)
true
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode;
import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase;
import eu.opencloud.android.utils.FileStorageUtils;
import eu.opencloud.android.utils.MediaLocationUtils;
import eu.opencloud.android.utils.UriUtils;
import kotlin.Lazy;
import org.jetbrains.annotations.NotNull;
Expand Down Expand Up @@ -148,7 +149,7 @@ protected ResultCode doInBackground(Object[] params) {
currentRemotePath = uploadPath + UriUtils.getDisplayNameForUri(currentUri, mAppContext);

fullTempPath = FileStorageUtils.getTemporalPath(account.name, spaceId) + currentRemotePath;
inputStream = leakedContentResolver.openInputStream(currentUri);
inputStream = MediaLocationUtils.openInputStreamPreservingLocation(mAppContext, leakedContentResolver, currentUri);
File cacheFile = new File(fullTempPath);
File tempDir = cacheFile.getParentFile();
if (!tempDir.exists()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* openCloud Android client application
*
* Copyright (C) 2026 OpenCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package eu.opencloud.android.utils

import android.Manifest
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import timber.log.Timber
import java.io.InputStream

/**
* Since Android 10 the system redacts location metadata (GPS EXIF) from photos and videos
* read through the media store or the storage access framework, unless the app holds
* [Manifest.permission.ACCESS_MEDIA_LOCATION] AND asks for the original via
* [MediaStore.setRequireOriginal]. Without this, uploads silently lose their location.
*/
object MediaLocationUtils {

@JvmStatic
fun isAccessMediaLocationGranted(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || hasMediaLocationPermission(context)

/**
* Returns a URI that yields the unredacted original of [uri] (location metadata intact),
* or [uri] itself when that is not possible: older Android, permission not granted, or a
* content URI that has no media store counterpart (non-media documents, cloud providers...).
*
* SAF document URIs (what automatic uploads use) are translated to their media store URI
* with [MediaStore.getMediaUri]; access is granted through the same document grant.
*/
@JvmStatic
fun getOriginalMediaUri(context: Context, uri: Uri): Uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getOriginalMediaUriApi29(context, uri) else uri

@RequiresApi(Build.VERSION_CODES.Q)
private fun hasMediaLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED

@RequiresApi(Build.VERSION_CODES.Q)
private fun getOriginalMediaUriApi29(context: Context, uri: Uri): Uri {
if (!hasMediaLocationPermission(context)) return uri

val mediaUri: Uri = when {
uri.authority == MediaStore.AUTHORITY -> uri
DocumentsContract.isDocumentUri(context, uri) -> try {
MediaStore.getMediaUri(context, uri)
} catch (e: Exception) {
Timber.d(e, "No media store URI for %s, uploading it as is", uri)
null
} ?: return uri
else -> return uri
}
return MediaStore.setRequireOriginal(mediaUri)
}

/**
* Opens [uri] for reading, preferring the unredacted original (see [getOriginalMediaUri]).
* Falls back to opening [uri] directly if the original can't be opened, so an upload never
* fails because of the location metadata.
*/
@JvmStatic
fun openInputStreamPreservingLocation(context: Context, contentResolver: ContentResolver, uri: Uri): InputStream? {
val originalUri = getOriginalMediaUri(context, uri)
if (originalUri != uri) {
try {
contentResolver.openInputStream(originalUri)?.let { return it }
} catch (e: Exception) {
Timber.w(e, "Could not open original media for %s, falling back to redacted copy", uri)
}
}
return contentResolver.openInputStream(uri)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import eu.opencloud.android.lib.resources.files.ReadRemoteFileOperation
import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation
import eu.opencloud.android.lib.resources.files.tus.TusChecksumHelper
import eu.opencloud.android.presentation.authentication.AccountUtils
import eu.opencloud.android.utils.MediaLocationUtils
import eu.opencloud.android.utils.MimetypeIconUtil
import eu.opencloud.android.utils.NotificationUtils
import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
Expand Down Expand Up @@ -251,7 +252,7 @@ class UploadFileFromContentUriWorker(

// openInputStream can return null if the content provider is unavailable or permissions were revoked.
// Failing here avoids silently uploading a 0-byte file.
val inputStream = appContext.contentResolver.openInputStream(contentUri)
val inputStream = MediaLocationUtils.openInputStreamPreservingLocation(appContext, appContext.contentResolver, contentUri)
Comment thread
bluecasita marked this conversation as resolved.
Resolved
if (inputStream == null) {
Timber.e("Failed to open input stream for %s — content provider unavailable or permissions revoked", contentUri)
throw LocalFileNotFoundException()
Expand Down Expand Up @@ -395,7 +396,8 @@ class UploadFileFromContentUriWorker(
}
// Always have the whole-file checksum: TUS sends it in Upload-Metadata, plain PUTs
// in the OC-Checksum header. Usually already persisted by copyFileToLocalStorage;
// this only reads the source again for cache-reuse runs of pre-checksum DB rows.
// this only hashes the cached copy again when it is reused without a stored checksum
// (pre-checksum DB rows, or TUS state cleared after a failed attempt).
ensureOriginalTusChecksum()

if (shouldTryTus) {
Expand Down Expand Up @@ -515,13 +517,16 @@ class UploadFileFromContentUriWorker(
private fun ensureOriginalTusChecksum() {
if (hasStoredSha1Checksum()) return

val inputStream = appContext.contentResolver.openInputStream(contentUri)
if (inputStream == null) {
Timber.e("Failed to open input stream for checksum source %s", contentUri)
// Hash the cached copy, not the content URI: the cache holds the bytes that are actually
// uploaded, and a second read of the source is not guaranteed to match them (the system
// redacts location metadata unless the unredacted original is requested and readable).
val cacheFile = File(cachePath)
if (!cacheFile.exists()) {
Timber.e("Cached upload file %s is missing, cannot compute its checksum", cachePath)
throw LocalFileNotFoundException()
}

val sha1Hex = inputStream.use { input ->
val sha1Hex = cacheFile.inputStream().use { input ->
TusChecksumHelper.sha1Hex(input)
}
persistTusChecksum(sha1Hex)
Expand Down
1 change: 1 addition & 0 deletions opencloudApp/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@
<string name="prefs_camera_upload_behaviour_title">Original file will be</string>
<string name="prefs_camera_upload_last_sync_title">Last synchronization</string>
<string name="notifications_permission_denied">You can update your preferences in Settings</string>
<string name="media_location_permission_denied">Without location access, uploaded photos and videos will lose their GPS location. You can grant it in Settings.</string>
<string name="upload_copy_files">Copy file</string>
<string name="upload_move_files">Move file</string>

Expand Down
Loading
Loading