Fix saving files to Nextcloud

pull/3336/head
Alex Baker 10 months ago
parent 1f659c3dc6
commit 3ada78b0f1

@ -6,12 +6,12 @@ import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import org.tasks.data.entity.Task
import com.todoroo.astrid.service.TaskCreator
import com.todoroo.astrid.utility.Constants
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.analytics.Firebase
import org.tasks.data.entity.Task
import org.tasks.data.entity.TaskAttachment
import org.tasks.files.FileHelper
import org.tasks.intents.TaskIntents
@ -87,17 +87,17 @@ class ShareLinkActivity : AppCompatActivity() {
startActivity(intent)
}
private fun copyAttachment(intent: Intent): ArrayList<Uri> =
private suspend fun copyAttachment(intent: Intent): ArrayList<Uri> =
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
?.let { copyAttachments(listOf(it)) }
?: arrayListOf()
private fun copyMultipleAttachments(intent: Intent): ArrayList<Uri> =
private suspend fun copyMultipleAttachments(intent: Intent): ArrayList<Uri> =
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?.let { copyAttachments(it) }
?: arrayListOf()
private fun copyAttachments(uris: List<Uri>) =
private suspend fun copyAttachments(uris: List<Uri>) =
uris
.filter {
it.scheme == ContentResolver.SCHEME_CONTENT

@ -22,9 +22,11 @@ import org.tasks.compose.edit.AttachmentRow
import org.tasks.data.dao.TaskAttachmentDao
import org.tasks.data.entity.TaskAttachment
import org.tasks.dialogs.AddAttachmentDialog
import org.tasks.extensions.Context.takePersistableUriPermission
import org.tasks.files.FileHelper
import org.tasks.preferences.Preferences
import org.tasks.ui.TaskEditControlFragment
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
@ -50,13 +52,18 @@ class FilesControlSet : TaskEditControlFragment() {
AttachmentRow(
attachments = viewState.attachments,
openAttachment = {
Timber.d("Clicked open $it")
FileHelper.startActionView(
requireActivity(),
context,
if (Strings.isNullOrEmpty(it.uri)) null else Uri.parse(it.uri)
)
},
deleteAttachment = { viewModel.setAttachments(viewState.attachments - it) },
deleteAttachment = {
Timber.d("Clicked delete $it")
viewModel.setAttachments(viewState.attachments - it)
},
addAttachment = {
Timber.d("Add attachment clicked")
AddAttachmentDialog.newAddAttachmentDialog(this@FilesControlSet)
.show(parentFragmentManager, FRAG_TAG_ADD_ATTACHMENT_DIALOG)
},
@ -69,20 +76,26 @@ class FilesControlSet : TaskEditControlFragment() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == AddAttachmentDialog.REQUEST_CAMERA || requestCode == AddAttachmentDialog.REQUEST_AUDIO) {
if (resultCode == Activity.RESULT_OK) {
val uri = data!!.data
copyToAttachmentDirectory(uri)
FileHelper.delete(requireContext(), uri)
lifecycleScope.launch {
val uri = data!!.data
copyToAttachmentDirectory(uri)
FileHelper.delete(requireContext(), uri)
}
}
} else if (requestCode == AddAttachmentDialog.REQUEST_STORAGE || requestCode == AddAttachmentDialog.REQUEST_GALLERY) {
if (resultCode == Activity.RESULT_OK) {
val clip = data!!.clipData
if (clip != null) {
for (i in 0 until clip.itemCount) {
val item = clip.getItemAt(i)
copyToAttachmentDirectory(item.uri)
lifecycleScope.launch {
val clip = data!!.clipData
if (clip != null) {
for (i in 0 until clip.itemCount) {
val item = clip.getItemAt(i)
requireContext().takePersistableUriPermission(item.uri)
copyToAttachmentDirectory(item.uri)
}
} else {
requireContext().takePersistableUriPermission(data.data!!)
copyToAttachmentDirectory(data.data)
}
} else {
copyToAttachmentDirectory(data.data)
}
}
} else {
@ -90,8 +103,17 @@ class FilesControlSet : TaskEditControlFragment() {
}
}
private fun copyToAttachmentDirectory(input: Uri?) {
newAttachment(FileHelper.copyToUri(requireContext(), preferences.attachmentsDirectory!!, input!!))
private suspend fun copyToAttachmentDirectory(input: Uri?) {
val destination = preferences.attachmentsDirectory ?: return
newAttachment(
if (FileHelper.isInTree(requireContext(), destination, input!!)) {
Timber.d("$input already exists in $destination")
input
} else {
Timber.d("Copying $input to $destination")
FileHelper.copyToUri(requireContext(), destination, input)
}
)
}
private fun newAttachment(output: Uri) {

@ -1,80 +0,0 @@
package org.tasks.activities;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.FileProvider;
import com.todoroo.astrid.utility.Constants;
import org.tasks.files.FileHelper;
import org.tasks.time.DateTime;
import java.io.File;
import java.io.IOException;
public class CameraActivity extends AppCompatActivity {
private static final int REQUEST_CODE_CAMERA = 75;
private static final String EXTRA_URI = "extra_output";
private Uri uri;
@SuppressLint("NewApi")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
uri = savedInstanceState.getParcelable(EXTRA_URI);
} else {
try {
uri =
FileHelper.newFile(
this,
Uri.fromFile(getCacheDir()),
"image/jpeg",
new DateTime().toString("yyyyMMddHHmm"),
".jpeg");
} catch (IOException e) {
throw new RuntimeException(e);
}
if (!uri.getScheme().equals(ContentResolver.SCHEME_FILE)) {
throw new RuntimeException("Invalid Uri");
}
final Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
final Uri shared =
FileProvider.getUriForFile(
this, Constants.FILE_PROVIDER_AUTHORITY, new File(uri.getPath()));
intent.putExtra(MediaStore.EXTRA_OUTPUT, shared);
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
startActivityForResult(intent, REQUEST_CODE_CAMERA);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_CAMERA) {
if (resultCode == RESULT_OK) {
final Intent intent = new Intent();
intent.setData(uri);
setResult(RESULT_OK, intent);
}
finish();
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putParcelable(EXTRA_URI, uri);
}
}

@ -0,0 +1,83 @@
package org.tasks.activities
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.todoroo.astrid.utility.Constants
import kotlinx.coroutines.launch
import org.tasks.files.FileHelper.newFile
import org.tasks.time.DateTime
import java.io.File
import java.io.IOException
class CameraActivity : AppCompatActivity() {
private var uri: Uri? = null
@SuppressLint("NewApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
uri = savedInstanceState.getParcelable(EXTRA_URI)
} else {
lifecycleScope.launch {
try {
uri =
newFile(
this@CameraActivity,
Uri.fromFile(cacheDir),
"image/jpeg",
DateTime().toString("yyyyMMddHHmm"),
".jpeg"
)
} catch (e: IOException) {
throw RuntimeException(e)
}
if (uri!!.scheme != ContentResolver.SCHEME_FILE) {
throw RuntimeException("Invalid Uri")
}
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
val shared =
FileProvider.getUriForFile(
this@CameraActivity, Constants.FILE_PROVIDER_AUTHORITY, File(
uri!!.path!!
)
)
intent.putExtra(MediaStore.EXTRA_OUTPUT, shared)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
startActivityForResult(intent, REQUEST_CODE_CAMERA)
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_CAMERA) {
if (resultCode == Activity.RESULT_OK) {
val intent = Intent()
intent.setData(uri)
setResult(Activity.RESULT_OK, intent)
}
finish()
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable(EXTRA_URI, uri)
}
companion object {
private const val REQUEST_CODE_CAMERA = 75
private const val EXTRA_URI = "extra_output"
}
}

@ -30,7 +30,6 @@ import com.todoroo.astrid.activity.TaskEditFragment.Companion.gesturesDisabled
import org.tasks.R
import org.tasks.compose.BeastModeBanner
import org.tasks.data.entity.UserActivity
import org.tasks.extensions.Context.findActivity
import org.tasks.files.FileHelper
import org.tasks.fragments.CommentBarFragment
import org.tasks.themes.TasksTheme
@ -123,14 +122,9 @@ fun TaskEditScreen(
val context = LocalContext.current
CommentsRow(
comments = comments,
copyCommentToClipboard = {
copyToClipboard(context, R.string.comment, it)
},
copyCommentToClipboard = { copyToClipboard(context, R.string.comment, it) },
deleteComment = deleteComment,
openImage = {
val activity = context.findActivity() ?: return@CommentsRow
FileHelper.startActionView(activity, it)
}
openImage = { FileHelper.startActionView(context, it) },
)
}
BeastModeBanner(

@ -72,6 +72,7 @@ class AddAttachmentDialog : DialogFragment() {
activity = activity,
initial = null,
allowMultiple = true,
persistPermissions = true,
),
REQUEST_STORAGE
)

@ -26,6 +26,7 @@ import com.todoroo.andlib.utility.AndroidUtilities.atLeastS
import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.notifications.NotificationManager.Companion.NOTIFICATION_CHANNEL_DEFAULT
import timber.log.Timber
object Context {
private const val HTTP = "http"
@ -149,4 +150,15 @@ object Context {
)
}
}
fun Context.takePersistableUriPermission(
uri: Uri,
mode: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
) {
try {
contentResolver.takePersistableUriPermission(uri, mode)
} catch (e: SecurityException) {
Timber.e(e)
}
}
}

@ -17,12 +17,15 @@ import com.google.common.collect.Iterables
import com.google.common.io.ByteStreams
import com.google.common.io.Files
import com.todoroo.astrid.utility.Constants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tasks.Strings.isNullOrEmpty
import org.tasks.extensions.Context.safeStartActivity
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.*
object FileHelper {
@ -30,9 +33,18 @@ object FileHelper {
activity: Activity?,
initial: Uri?,
allowMultiple: Boolean = false,
persistPermissions: Boolean = false,
vararg mimeTypes: String?,
): Intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
if (persistPermissions) {
addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
or Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
}
putExtra("android.content.extra.SHOW_ADVANCED", true)
putExtra("android.content.extra.FANCY", true)
putExtra("android.content.extra.SHOW_FILESIZE", true)
@ -130,6 +142,24 @@ object FileHelper {
Files.getFileExtension(getFilename(context, uri)!!)
}
suspend fun fileExists(context: Context, uri: Uri): Boolean = withContext(Dispatchers.IO) {
when (uri.scheme) {
ContentResolver.SCHEME_FILE -> {
File(uri.path!!).exists()
}
ContentResolver.SCHEME_CONTENT -> {
try {
val documentFile = DocumentFile.fromSingleUri(context, uri)
documentFile?.exists() == true && documentFile.length() > 0
} catch (e: Exception) {
Timber.e(e)
false
}
}
else -> false
}
}
fun getMimeType(context: Context, uri: Uri): String? {
val mimeType = context.contentResolver.getType(uri)
if (!isNullOrEmpty(mimeType)) {
@ -139,28 +169,33 @@ object FileHelper {
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
}
fun startActionView(context: Activity, uri: Uri?) {
fun startActionView(context: Context, uri: Uri?) {
var uri = uri ?: return
val mimeType = getMimeType(context, uri)
val intent = Intent(Intent.ACTION_VIEW)
if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
uri = copyToUri(context, Uri.fromFile(context.cacheDir), uri)
intent.setDataAndType(uri, mimeType)
} else {
val share = FileProvider.getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, File(uri.path))
intent.setDataAndType(share, mimeType)
}
val share = FileProvider.getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, File(uri.path))
intent.setDataAndType(share, mimeType)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.safeStartActivity(intent)
}
@JvmStatic
@Throws(IOException::class)
fun newFile(
context: Context, destination: Uri, mimeType: String?, baseName: String, extension: String?): Uri {
suspend fun newFile(
context: Context, destination: Uri, mimeType: String?, baseName: String, extension: String?): Uri = withContext(Dispatchers.IO) {
val filename = getNonCollidingFileName(context, destination, baseName, extension)
return when (destination.scheme) {
when (destination.scheme) {
"content" -> {
val tree = DocumentFile.fromTreeUri(context, destination)
val f1 = tree!!.createFile(mimeType!!, filename)
?: throw IOException("Failed to access directory: $destination")
if (!tree.canWrite()) {
throw IOException("No write permission for directory: $destination")
}
val f1 = tree.createFile(mimeType ?: "application/octet-stream", filename)
?: throw FileNotFoundException("Failed to create $filename")
f1.uri
}
@ -171,15 +206,16 @@ object FileHelper {
}
val f2 = File(dir.absolutePath + File.separator + filename)
if (f2.createNewFile()) {
return Uri.fromFile(f2)
Uri.fromFile(f2)
} else {
throw FileNotFoundException("Failed to create $filename")
}
throw FileNotFoundException("Failed to create $filename")
}
else -> throw IllegalArgumentException("Unknown URI scheme: " + destination.scheme)
}
}
fun copyToUri(context: Context, destination: Uri, input: Uri): Uri {
suspend fun copyToUri(context: Context, destination: Uri, input: Uri): Uri {
val filename = getFilename(context, input)
val basename = Files.getNameWithoutExtension(filename!!)
try {
@ -197,7 +233,53 @@ object FileHelper {
}
}
fun copyStream(context: Context, input: Uri?, output: Uri?) {
suspend fun isInTree(context: Context, treeUri: Uri, documentUri: Uri): Boolean = withContext(Dispatchers.IO) {
if (treeUri.authority != documentUri.authority) {
return@withContext false
}
try {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: return@withContext false
val documentFile = DocumentFile.fromSingleUri(context, documentUri)
?: return@withContext false
val name = documentFile.name ?: return@withContext false
val treeFile = tree.findFile(name) ?: return@withContext false
val contentResolver = context.contentResolver
contentResolver.openInputStream(documentUri)?.use { input1 ->
contentResolver.openInputStream(treeFile.uri)?.use { input2 ->
compareStreams(input1, input2)
}
} ?: false
} catch (e: Exception) {
Timber.e(e)
false
}
}
private fun compareStreams(input1: InputStream, input2: InputStream): Boolean {
val buffer1 = ByteArray(8192)
val buffer2 = ByteArray(8192)
while (true) {
val count1 = input1.read(buffer1)
val count2 = input2.read(buffer2)
if (count1 != count2) {
return false
}
if (count1 == -1) {
return true
}
if (!buffer1.contentEquals(buffer2)) {
return false
}
}
}
suspend fun copyStream(context: Context, input: Uri?, output: Uri?) = withContext(Dispatchers.IO) {
val contentResolver = context.contentResolver
try {
val inputStream = contentResolver.openInputStream(input!!)

@ -13,6 +13,7 @@ import org.tasks.calendars.CalendarEventProvider
import org.tasks.data.dao.TaskDao
import org.tasks.data.db.Database
import org.tasks.etebase.EtebaseLocalCache
import org.tasks.extensions.Context.takePersistableUriPermission
import org.tasks.extensions.Context.toast
import org.tasks.files.FileHelper
import org.tasks.injection.InjectingPreferenceFragment
@ -73,11 +74,7 @@ class Advanced : InjectingPreferenceFragment() {
if (requestCode == REQUEST_CODE_FILES_DIR) {
if (resultCode == Activity.RESULT_OK) {
val uri = data!!.data!!
requireContext().contentResolver
.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
requireContext().takePersistableUriPermission(uri)
preferences.setUri(R.string.p_attachment_dir, uri)
updateAttachmentDirectory()
}

@ -12,6 +12,7 @@ import org.tasks.R
import org.tasks.dialogs.ExportTasksDialog
import org.tasks.dialogs.ImportTasksDialog
import org.tasks.drive.DriveLoginActivity
import org.tasks.extensions.Context.takePersistableUriPermission
import org.tasks.extensions.Context.toast
import org.tasks.files.FileHelper
import org.tasks.injection.InjectingPreferenceFragment
@ -176,11 +177,7 @@ class Backups : InjectingPreferenceFragment() {
if (requestCode == REQUEST_CODE_BACKUP_DIR) {
if (resultCode == RESULT_OK && data != null) {
val uri = data.data!!
context?.contentResolver
?.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
context?.takePersistableUriPermission(uri)
preferences.setUri(R.string.p_backup_dir, uri)
updateBackupDirectory()
viewModel.updateLocalBackup()

@ -524,7 +524,7 @@ class TaskEditViewModel @Inject constructor(
}
}
fun addComment(message: String?, picture: Uri?) {
fun addComment(message: String?, picture: Uri?) = viewModelScope.launch(NonCancellable) {
val userActivity = UserActivity()
if (picture != null) {
val output = FileHelper.copyToUri(context, preferences.attachmentsDirectory!!, picture)
@ -533,11 +533,7 @@ class TaskEditViewModel @Inject constructor(
userActivity.message = message
userActivity.targetId = task.uuid
userActivity.created = currentTimeMillis()
viewModelScope.launch {
withContext(NonCancellable) {
userActivityDao.createNew(userActivity)
}
}
userActivityDao.createNew(userActivity)
}
fun hideBeastModeHint(click: Boolean) {
@ -621,10 +617,14 @@ class TaskEditViewModel @Inject constructor(
init {
viewModelScope.launch {
taskAttachmentDao.getAttachments(task.id).toPersistentSet().let { attachments ->
_originalState.update { it.copy(attachments = attachments) }
_viewState.update { it.copy(attachments = attachments) }
}
taskAttachmentDao
.getAttachments(task.id)
.filter { FileHelper.fileExists(context, Uri.parse(it.uri)) }
.toPersistentSet()
.let { attachments ->
_originalState.update { it.copy(attachments = attachments) }
_viewState.update { it.copy(attachments = attachments) }
}
}
if (!task.isNew) {
viewModelScope.launch {

Loading…
Cancel
Save