Skip to content
Open
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 @@ -22,6 +22,7 @@

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

import android.Manifest
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
Expand Down Expand Up @@ -55,6 +56,7 @@ import eu.opencloud.android.extensions.showMessageInSnackbar
import eu.opencloud.android.presentation.accounts.ManageAccountsViewModel
import eu.opencloud.android.ui.activity.FolderPickerActivity
import eu.opencloud.android.utils.DisplayUtils
import eu.opencloud.android.utils.MediaLocationUtils
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
Expand Down Expand Up @@ -93,6 +95,22 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
picturesViewModel.handleSelectPictureUploadsSourcePath(contentUriForTree)
}

// Without ACCESS_MEDIA_LOCATION, Android strips GPS EXIF from the pictures we read (API >= 29).
// Asked only here, when picture uploads are enabled, not on app start.
private var mediaLocationPermissionRequested = false
private val requestMediaLocationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
showMessageInSnackbar(getString(R.string.media_location_permission_denied))
}
}

private fun requestMediaLocationPermissionIfNeeded() {
if (mediaLocationPermissionRequested || MediaLocationUtils.isAccessMediaLocationGranted(requireContext())) return
mediaLocationPermissionRequested = true
requestMediaLocationPermissionLauncher.launch(Manifest.permission.ACCESS_MEDIA_LOCATION)
}

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

Expand Down Expand Up @@ -149,6 +167,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration ->
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
requestMediaLocationPermissionIfNeeded()
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
prefPictureUploadsSourcePath?.summary = DisplayUtils.getPathWithoutLastSlash(it.sourcePath.toUri().path)
Expand All @@ -171,6 +190,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {

if (value) {
picturesViewModel.enablePictureUploads(selectedAccount)
mediaLocationPermissionRequested = false
requestMediaLocationPermissionIfNeeded()
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.proper_pics_folder_warning_camera_upload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

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

import android.Manifest
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
Expand Down Expand Up @@ -55,6 +56,7 @@ import eu.opencloud.android.extensions.showMessageInSnackbar
import eu.opencloud.android.presentation.accounts.ManageAccountsViewModel
import eu.opencloud.android.ui.activity.FolderPickerActivity
import eu.opencloud.android.utils.DisplayUtils
import eu.opencloud.android.utils.MediaLocationUtils
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
Expand Down Expand Up @@ -93,6 +95,22 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
videosViewModel.handleSelectVideoUploadsSourcePath(contentUriForTree)
}

// Without ACCESS_MEDIA_LOCATION, Android strips GPS metadata from the videos we read (API >= 29).
// Asked only here, when video uploads are enabled, not on app start.
private var mediaLocationPermissionRequested = false
private val requestMediaLocationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
showMessageInSnackbar(getString(R.string.media_location_permission_denied))
}
}

private fun requestMediaLocationPermissionIfNeeded() {
if (mediaLocationPermissionRequested || MediaLocationUtils.isAccessMediaLocationGranted(requireContext())) return
mediaLocationPermissionRequested = true
requestMediaLocationPermissionLauncher.launch(Manifest.permission.ACCESS_MEDIA_LOCATION)
}

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

Expand Down Expand Up @@ -147,6 +165,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
videosViewModel.videoUploads.collect { videoUploadsConfiguration ->
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
requestMediaLocationPermissionIfNeeded()
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
prefVideoUploadsSourcePath?.summary = DisplayUtils.getPathWithoutLastSlash(it.sourcePath.toUri().path)
Expand All @@ -169,6 +188,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {

if (value) {
videosViewModel.enableVideoUploads(selectedAccount)
mediaLocationPermissionRequested = false
requestMediaLocationPermissionIfNeeded()
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.proper_videos_folder_warning_camera_upload)
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,90 @@
/**
* 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.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 ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_MEDIA_LOCATION) ==
PackageManager.PERMISSION_GRANTED

/**
* 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 || !isAccessMediaLocationGranted(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)
if (inputStream == null) {
Timber.e("Failed to open input stream for %s — content provider unavailable or permissions revoked", contentUri)
throw LocalFileNotFoundException()
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 @@ -591,6 +591,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* 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.app.Application
import android.net.Uri
import android.provider.MediaStore
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class MediaLocationUtilsTest {

private val context: Application = ApplicationProvider.getApplicationContext()
private val mediaStoreUri: Uri = Uri.parse("content://${MediaStore.AUTHORITY}/external/images/media/42")
private val otherProviderUri: Uri = Uri.parse("content://com.example.provider/files/42")

private fun grantMediaLocationPermission() {
shadowOf(context).grantPermissions(Manifest.permission.ACCESS_MEDIA_LOCATION)
}

@Test
@Config(sdk = [28])
fun permission_is_implicitly_granted_below_android_10() {
assertTrue(MediaLocationUtils.isAccessMediaLocationGranted(context))
}

@Test
@Config(sdk = [29])
fun permission_is_not_granted_by_default_on_android_10() {
assertFalse(MediaLocationUtils.isAccessMediaLocationGranted(context))
}

@Test
@Config(sdk = [28])
fun original_uri_is_untouched_below_android_10() {
assertEquals(mediaStoreUri, MediaLocationUtils.getOriginalMediaUri(context, mediaStoreUri))
}

@Test
@Config(sdk = [29])
fun original_uri_is_untouched_without_permission() {
assertEquals(mediaStoreUri, MediaLocationUtils.getOriginalMediaUri(context, mediaStoreUri))
}

@Test
@Config(sdk = [29])
fun media_store_uri_requires_original_when_permission_granted() {
grantMediaLocationPermission()

val originalUri = MediaLocationUtils.getOriginalMediaUri(context, mediaStoreUri)

assertEquals(MediaStore.setRequireOriginal(mediaStoreUri), originalUri)
assertEquals("1", originalUri.getQueryParameter("requireOriginal"))
}

@Test
@Config(sdk = [29])
fun non_media_uri_is_untouched_even_with_permission() {
grantMediaLocationPermission()

assertEquals(otherProviderUri, MediaLocationUtils.getOriginalMediaUri(context, otherProviderUri))
}
}