Use storage access framework for attachments

pull/795/head
Alex Baker 7 years ago
parent 09156c5243
commit 593b5bc5c5

@ -14,11 +14,6 @@ public class PermissivePermissionChecker extends PermissionChecker {
return true;
}
@Override
public boolean canWriteToExternalStorage() {
return true;
}
@Override
public boolean canAccessAccounts() {
return true;

@ -134,40 +134,6 @@ public class AndroidUtilities {
}
}
/** Copy a file from one place to another */
public static void copyFile(File in, File out) throws Exception {
FileInputStream fis = new FileInputStream(in);
FileOutputStream fos = new FileOutputStream(out);
try {
copyStream(fis, fos);
} finally {
fis.close();
fos.close();
}
}
/** Copy stream from source to destination */
private static void copyStream(InputStream source, OutputStream dest) throws IOException {
int bytes;
byte[] buffer;
int BUFFER_SIZE = 1024;
buffer = new byte[BUFFER_SIZE];
while ((bytes = source.read(buffer)) != -1) {
if (bytes == 0) {
bytes = source.read();
if (bytes < 0) {
break;
}
dest.write(bytes);
dest.flush();
continue;
}
dest.write(buffer, 0, bytes);
dest.flush();
}
}
public static int convertDpToPixels(DisplayMetrics displayMetrics, int dp) {
// developer.android.com/guide/practices/screens_support.html#dips-pels
return (int) (dp * displayMetrics.density + 0.5f);

@ -14,6 +14,7 @@ import android.annotation.SuppressLint;
import androidx.lifecycle.ViewModelProviders;
import android.content.Intent;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentManager;
@ -464,7 +465,7 @@ public class MainActivity extends InjectingAppCompatActivity
}
@Override
public void addComment(String message, String picture) {
public void addComment(String message, Uri picture) {
TaskEditFragment taskEditFragment = getTaskEditFragment();
if (taskEditFragment != null) {
taskEditFragment.addComment(message, picture);

@ -5,23 +5,17 @@
*/
package com.todoroo.astrid.activity;
import static org.tasks.date.DateTimeUtils.newDateTime;
import android.app.Activity;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.core.content.ContextCompat;
import androidx.appcompat.widget.Toolbar;
import android.text.format.DateUtils;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.todoroo.andlib.utility.AndroidUtilities;
import com.todoroo.andlib.utility.DateUtilities;
import com.todoroo.astrid.dao.TaskDao;
@ -33,8 +27,7 @@ import com.todoroo.astrid.service.TaskDeleter;
import com.todoroo.astrid.timers.TimerPlugin;
import com.todoroo.astrid.ui.EditTitleControlSet;
import com.todoroo.astrid.utility.Flags;
import java.util.List;
import javax.inject.Inject;
import org.tasks.LocalBroadcastManager;
import org.tasks.R;
import org.tasks.analytics.Tracker;
@ -50,6 +43,22 @@ import org.tasks.preferences.Preferences;
import org.tasks.ui.MenuColorizer;
import org.tasks.ui.TaskEditControlFragment;
import java.io.IOException;
import java.util.List;
import javax.inject.Inject;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import butterknife.BindView;
import butterknife.ButterKnife;
import timber.log.Timber;
import static org.tasks.date.DateTimeUtils.newDateTime;
import static org.tasks.files.FileHelper.copyToUri;
public final class TaskEditFragment extends InjectingFragment
implements Toolbar.OnMenuItemClickListener {
@ -320,14 +329,15 @@ public final class TaskEditFragment extends InjectingFragment
}
}
public void addComment(String message, String picture) {
void addComment(String message, Uri picture) {
UserActivity userActivity = new UserActivity();
if (picture != null) {
Uri output = copyToUri(context, preferences.getAttachmentsDirectory(), picture);
userActivity.setPicture(output);
}
userActivity.setMessage(message);
userActivity.setTargetId(model.getUuid());
userActivity.setCreated(DateUtilities.now());
if (picture != null) {
userActivity.setPicture(picture);
}
userActivityDao.createNew(userActivity);
commentsController.reloadView();
}

@ -39,14 +39,11 @@ import org.xmlpull.v1.XmlPullParserFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import javax.inject.Inject;
import timber.log.Timber;
import static org.tasks.files.FileHelper.fromUri;
public class TasksXmlImporter {
private static final String FORMAT2 = "2"; // $NON-NLS-1$
@ -120,7 +117,7 @@ public class TasksXmlImporter {
private void performImport() throws IOException, XmlPullParserException {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
XmlPullParser xpp = factory.newPullParser();
InputStream inputStream = fromUri(activity, input);
InputStream inputStream = activity.getContentResolver().openInputStream(input);
InputStreamReader reader = new InputStreamReader(inputStream);
xpp.setInput(reader);

@ -5,55 +5,43 @@
*/
package com.todoroo.astrid.files;
import static android.app.Activity.RESULT_OK;
import static com.google.common.base.Strings.isNullOrEmpty;
import static org.tasks.dialogs.AddAttachmentDialog.REQUEST_STORAGE;
import static org.tasks.dialogs.AddAttachmentDialog.newAddAttachmentDialog;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.MimeTypeMap;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import butterknife.BindView;
import butterknife.OnClick;
import com.google.common.base.Strings;
import com.todoroo.andlib.utility.AndroidUtilities;
import com.todoroo.astrid.data.Task;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
import org.tasks.R;
import org.tasks.activities.CameraActivity;
import org.tasks.data.TaskAttachment;
import org.tasks.data.TaskAttachmentDao;
import org.tasks.dialogs.AddAttachmentDialog;
import org.tasks.dialogs.DialogBuilder;
import org.tasks.files.FileExplore;
import org.tasks.files.FileHelper;
import org.tasks.injection.ForActivity;
import org.tasks.injection.FragmentComponent;
import org.tasks.preferences.Preferences;
import org.tasks.ui.TaskEditControlFragment;
import timber.log.Timber;
import javax.inject.Inject;
import androidx.annotation.Nullable;
import butterknife.BindView;
import butterknife.OnClick;
import static android.app.Activity.RESULT_OK;
import static org.tasks.data.TaskAttachment.createNewAttachment;
import static org.tasks.dialogs.AddAttachmentDialog.REQUEST_AUDIO;
import static org.tasks.dialogs.AddAttachmentDialog.REQUEST_CAMERA;
import static org.tasks.dialogs.AddAttachmentDialog.REQUEST_GALLERY;
import static org.tasks.dialogs.AddAttachmentDialog.REQUEST_STORAGE;
import static org.tasks.dialogs.AddAttachmentDialog.newAddAttachmentDialog;
import static org.tasks.files.FileHelper.copyToUri;
public class FilesControlSet extends TaskEditControlFragment {
@ -75,19 +63,6 @@ public class FilesControlSet extends TaskEditControlFragment {
private String taskUuid;
private static void play(String file, PlaybackExceptionHandler handler) {
MediaPlayer mediaPlayer = new MediaPlayer();
try {
mediaPlayer.setDataSource(file);
mediaPlayer.prepare();
mediaPlayer.start();
} catch (Exception e) {
Timber.e(e);
handler.playbackFailed();
}
}
@Nullable
@Override
public View onCreateView(
@ -96,12 +71,9 @@ public class FilesControlSet extends TaskEditControlFragment {
taskUuid = task.getUuid();
final List<TaskAttachment> files = new ArrayList<>();
for (TaskAttachment attachment : taskAttachmentDao.getAttachments(taskUuid)) {
files.add(attachment);
addAttachment(attachment);
}
validateFiles(files);
return view;
}
@ -131,46 +103,12 @@ public class FilesControlSet extends TaskEditControlFragment {
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == AddAttachmentDialog.REQUEST_CAMERA) {
if (requestCode == REQUEST_CAMERA
|| requestCode == REQUEST_STORAGE
|| requestCode == REQUEST_GALLERY
|| requestCode == REQUEST_AUDIO) {
if (resultCode == RESULT_OK) {
Uri uri = data.getParcelableExtra(CameraActivity.EXTRA_URI);
final File file = new File(uri.getPath());
String path = file.getPath();
Timber.i("Saved %s", file.getAbsolutePath());
final String extension = path.substring(path.lastIndexOf('.') + 1);
createNewFileAttachment(file, TaskAttachment.FILE_TYPE_IMAGE + extension);
}
} else if (requestCode == AddAttachmentDialog.REQUEST_AUDIO) {
if (resultCode == Activity.RESULT_OK) {
String path = data.getStringExtra(AddAttachmentDialog.EXTRA_PATH);
String type = data.getStringExtra(AddAttachmentDialog.EXTRA_TYPE);
createNewFileAttachment(new File(path), type);
}
} else if (requestCode == AddAttachmentDialog.REQUEST_GALLERY) {
if (resultCode == RESULT_OK) {
Uri uri = data.getData();
ContentResolver contentResolver = context.getContentResolver();
MimeTypeMap mime = MimeTypeMap.getSingleton();
final String extension = mime.getExtensionFromMimeType(contentResolver.getType(uri));
final File tempFile = getFilename(extension);
Timber.i("Writing %s to %s", uri, tempFile);
try {
InputStream inputStream = contentResolver.openInputStream(uri);
copyFile(inputStream, tempFile.getPath());
} catch (IOException e) {
throw new RuntimeException(e);
}
createNewFileAttachment(tempFile, TaskAttachment.FILE_TYPE_IMAGE + extension);
}
} else if (requestCode == REQUEST_STORAGE) {
if (resultCode == RESULT_OK) {
String path = data.getStringExtra(FileExplore.EXTRA_FILE);
final String destination = copyToAttachmentDirectory(path);
if (destination != null) {
Timber.i("Copied %s to %s", path, destination);
final String extension = destination.substring(path.lastIndexOf('.') + 1);
createNewFileAttachment(new File(path), TaskAttachment.FILE_TYPE_IMAGE + extension);
}
copyToAttachmentDirectory(data.getData());
}
} else {
super.onActivityResult(requestCode, resultCode, data);
@ -199,32 +137,13 @@ public class FilesControlSet extends TaskEditControlFragment {
android.R.string.ok,
(dialog, which) -> {
taskAttachmentDao.delete(taskAttachment);
if (!Strings.isNullOrEmpty(taskAttachment.getPath())) {
File f = new File(taskAttachment.getPath());
f.delete();
}
FileHelper.delete(context, taskAttachment.parseUri());
attachmentContainer.removeView(fileRow);
})
.setNegativeButton(android.R.string.cancel, null)
.show());
}
private void validateFiles(List<TaskAttachment> files) {
for (int i = 0; i < files.size(); i++) {
TaskAttachment m = files.get(i);
if (!Strings.isNullOrEmpty(m.getPath())) {
File f = new File(m.getPath());
if (!f.exists()) {
m.setPath(""); // $NON-NLS-1$
// No local file and no url -- delete the metadata
taskAttachmentDao.delete(m);
files.remove(i);
i--;
}
}
}
}
@Override
protected void inject(FragmentComponent component) {
component.inject(this);
@ -232,113 +151,17 @@ public class FilesControlSet extends TaskEditControlFragment {
@SuppressLint("NewApi")
private void showFile(final TaskAttachment m) {
final String fileType =
!Strings.isNullOrEmpty(m.getContentType())
? m.getContentType()
: TaskAttachment.FILE_TYPE_OTHER;
final String filePath = m.getPath();
if (fileType.startsWith(TaskAttachment.FILE_TYPE_AUDIO)) {
play(m.getPath(), () -> showFromIntent(filePath, fileType));
} else if (fileType.startsWith(TaskAttachment.FILE_TYPE_IMAGE)) {
try {
Intent intent =
FileHelper.getReadableActionView(
context, filePath, TaskAttachment.FILE_TYPE_IMAGE + "*");
getActivity().startActivity(intent);
} catch (ActivityNotFoundException e) {
Timber.e(e);
Toast.makeText(context, R.string.no_application_found, Toast.LENGTH_SHORT).show();
}
} else {
String useType = fileType;
if (fileType.equals(TaskAttachment.FILE_TYPE_OTHER)) {
String extension = AndroidUtilities.getFileExtension(filePath);
MimeTypeMap map = MimeTypeMap.getSingleton();
String guessedType = map.getMimeTypeFromExtension(extension);
if (!TextUtils.isEmpty(guessedType)) {
useType = guessedType;
}
if (!useType.equals(guessedType)) {
m.setContentType(useType);
taskAttachmentDao.update(m);
}
}
showFromIntent(filePath, useType);
final Uri uri = m.parseUri();
if (uri != null) {
FileHelper.startActionView(getActivity(), uri);
}
}
private void showFromIntent(String file, String type) {
try {
Intent intent = FileHelper.getReadableActionView(context, file, type);
getActivity().startActivity(intent);
} catch (ActivityNotFoundException e) {
Timber.e(e);
Toast.makeText(context, R.string.file_type_unhandled, Toast.LENGTH_LONG).show();
}
}
private void createNewFileAttachment(File file, String fileType) {
private void copyToAttachmentDirectory(Uri input) {
Uri output = copyToUri(context, preferences.getAttachmentsDirectory(), input);
TaskAttachment attachment =
TaskAttachment.createNewAttachment(
taskUuid, file.getAbsolutePath(), file.getName(), fileType);
createNewAttachment(taskUuid, output, FileHelper.getFilename(context, output));
taskAttachmentDao.createNew(attachment);
addAttachment(attachment);
}
private File getFilename(String extension) {
AtomicReference<String> nameRef = new AtomicReference<>();
if (isNullOrEmpty(extension)) {
extension = "";
} else if (!extension.startsWith(".")) {
extension = "." + extension;
}
try {
String path = preferences.getNewAttachmentPath(extension, nameRef);
File file = new File(path);
file.getParentFile().mkdirs();
if (!file.createNewFile()) {
throw new RuntimeException("Failed to create " + file.getPath());
}
return file;
} catch (IOException e) {
Timber.e(e);
}
return null;
}
private void copyFile(InputStream inputStream, String to) throws IOException {
FileOutputStream fos = new FileOutputStream(to);
byte[] buf = new byte[1024];
int len;
while ((len = inputStream.read(buf)) != -1) {
fos.write(buf, 0, len);
}
fos.close();
}
private String copyToAttachmentDirectory(String file) {
File src = new File(file);
if (!src.exists()) {
Toast.makeText(context, R.string.file_err_copy, Toast.LENGTH_LONG).show();
return null;
}
File dst = new File(preferences.getAttachmentsDirectory() + File.separator + src.getName());
try {
AndroidUtilities.copyFile(src, dst);
} catch (Exception e) {
Timber.e(e);
Toast.makeText(context, R.string.file_err_copy, Toast.LENGTH_LONG).show();
return null;
}
return dst.getAbsolutePath();
}
interface PlaybackExceptionHandler {
void playbackFailed();
}
}

@ -5,15 +5,9 @@
*/
package com.todoroo.astrid.notes;
import static androidx.core.content.ContextCompat.getColor;
import static org.tasks.files.FileHelper.getPathFromUri;
import static org.tasks.files.ImageHelper.sampleBitmap;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Color;
import android.net.Uri;
import androidx.core.content.FileProvider;
import android.text.Html;
import android.text.util.Linkify;
import android.view.View;
@ -21,18 +15,23 @@ import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import com.todoroo.andlib.utility.DateUtilities;
import com.todoroo.astrid.data.Task;
import com.todoroo.astrid.utility.Constants;
import java.io.File;
import java.util.ArrayList;
import javax.inject.Inject;
import org.tasks.R;
import org.tasks.data.UserActivity;
import org.tasks.data.UserActivityDao;
import org.tasks.files.FileHelper;
import org.tasks.preferences.Preferences;
import java.util.ArrayList;
import javax.inject.Inject;
import static androidx.core.content.ContextCompat.getColor;
import static org.tasks.files.ImageHelper.sampleBitmap;
public class CommentsController {
private final UserActivityDao userActivityDao;
@ -53,27 +52,17 @@ public class CommentsController {
}
private static void setupImagePopupForCommentView(
View view, ImageView commentPictureView, final Uri updateBitmap, final Activity activity) {
if (updateBitmap != null) {
View view, ImageView commentPictureView, final Uri uri, final Activity activity) {
if (uri != null) {
commentPictureView.setVisibility(View.VISIBLE);
String path = getPathFromUri(activity, updateBitmap);
commentPictureView.setImageBitmap(
sampleBitmap(
path,
activity,
uri,
commentPictureView.getLayoutParams().width,
commentPictureView.getLayoutParams().height));
view.setOnClickListener(
v -> {
File file = new File(updateBitmap.getPath());
Uri uri =
FileProvider.getUriForFile(
activity, Constants.FILE_PROVIDER_AUTHORITY, file.getAbsoluteFile());
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, "image/*");
FileHelper.grantReadPermissions(activity, intent, uri);
activity.startActivity(intent);
});
view.setOnClickListener(v -> FileHelper.startActionView(activity, uri));
} else {
commentPictureView.setVisibility(View.GONE);
}

@ -5,9 +5,8 @@
*/
package com.todoroo.astrid.service;
import static com.google.common.base.Strings.isNullOrEmpty;
import android.content.Context;
import android.net.Uri;
import android.os.Environment;
import com.google.common.base.Strings;
@ -18,9 +17,7 @@ import com.google.common.collect.Multimaps;
import com.todoroo.astrid.api.GtasksFilter;
import com.todoroo.astrid.dao.Database;
import com.todoroo.astrid.tags.TagService;
import java.io.File;
import java.util.List;
import javax.inject.Inject;
import org.tasks.BuildConfig;
import org.tasks.LocalBroadcastManager;
import org.tasks.R;
@ -35,12 +32,24 @@ import org.tasks.data.Tag;
import org.tasks.data.TagDao;
import org.tasks.data.TagData;
import org.tasks.data.TagDataDao;
import org.tasks.data.TaskAttachment;
import org.tasks.data.TaskAttachmentDao;
import org.tasks.data.UserActivity;
import org.tasks.data.UserActivityDao;
import org.tasks.injection.ForApplication;
import org.tasks.preferences.DefaultFilterProvider;
import org.tasks.preferences.Preferences;
import org.tasks.scheduling.BackgroundScheduler;
import java.io.File;
import java.util.List;
import javax.inject.Inject;
import timber.log.Timber;
import static com.google.common.base.Strings.isNullOrEmpty;
public class StartupService {
private static final int V4_8_0 = 380;
@ -61,6 +70,8 @@ public class StartupService {
private final FilterDao filterDao;
private final DefaultFilterProvider defaultFilterProvider;
private final GoogleTaskListDao googleTaskListDao;
private final UserActivityDao userActivityDao;
private final TaskAttachmentDao taskAttachmentDao;
@Inject
public StartupService(
@ -74,7 +85,9 @@ public class StartupService {
TagDao tagDao,
FilterDao filterDao,
DefaultFilterProvider defaultFilterProvider,
GoogleTaskListDao googleTaskListDao) {
GoogleTaskListDao googleTaskListDao,
UserActivityDao userActivityDao,
TaskAttachmentDao taskAttachmentDao) {
this.database = database;
this.preferences = preferences;
this.tracker = tracker;
@ -86,6 +99,8 @@ public class StartupService {
this.filterDao = filterDao;
this.defaultFilterProvider = defaultFilterProvider;
this.googleTaskListDao = googleTaskListDao;
this.userActivityDao = userActivityDao;
this.taskAttachmentDao = taskAttachmentDao;
}
/** Called when this application is started up */
@ -198,18 +213,32 @@ public class StartupService {
}
private void migrateUris() {
String backupDirectory = preferences.getStringValue(R.string.p_backup_dir);
if (!Strings.isNullOrEmpty(backupDirectory)) {
File file = new File(backupDirectory);
migrateUriPreference(R.string.p_backup_dir);
migrateUriPreference(R.string.p_attachment_dir);
for (UserActivity userActivity : userActivityDao.getComments()) {
userActivity.convertPictureUri();
userActivityDao.update(userActivity);
}
for (TaskAttachment attachment : taskAttachmentDao.getAttachments()) {
attachment.convertPathUri();
taskAttachmentDao.update(attachment);
}
}
private void migrateUriPreference(int pref) {
String path = preferences.getStringValue(pref);
if (Strings.isNullOrEmpty(path)) {
return;
}
File file = new File(path);
try {
if (file.canWrite()) {
preferences.setUri(R.string.p_backup_dir, file.toURI());
preferences.setUri(pref, file.toURI());
} else {
preferences.remove(R.string.p_backup_dir);
preferences.remove(pref);
}
} catch (SecurityException ignored) {
preferences.remove(R.string.p_backup_dir);
}
preferences.remove(pref);
}
}

@ -1,36 +1,49 @@
package com.todoroo.astrid.voice;
import androidx.lifecycle.ViewModel;
import android.content.Context;
import android.media.MediaRecorder;
import android.net.Uri;
import android.os.SystemClock;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.tasks.files.FileHelper;
import org.tasks.preferences.Preferences;
import org.tasks.time.DateTime;
import timber.log.Timber;
public class AACRecorder extends ViewModel {
private MediaRecorder mediaRecorder;
private final AtomicReference<String> nameRef = new AtomicReference<>();
private boolean recording;
private AACRecorderCallbacks listener;
private Preferences preferences;
private long base;
private String tempFile;
private Uri uri;
public synchronized void startRecording() {
public synchronized void startRecording(Context context) throws IOException {
if (recording) {
return;
}
tempFile = preferences.getNewAudioAttachmentPath(nameRef);
uri =
FileHelper.newFile(
context,
preferences.getCacheDirectory(),
"audio/m4a",
new DateTime().toString("yyyyMMddHHmm"),
".m4a");
mediaRecorder = new MediaRecorder();
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
mediaRecorder.setOutputFile(tempFile);
mediaRecorder.setOutputFile(uri.getPath());
mediaRecorder.setOnErrorListener(
(mr, what, extra) -> Timber.e("mediaRecorder.onError(mr, %s, %s)", what, extra));
mediaRecorder.setOnInfoListener(
@ -63,7 +76,7 @@ public class AACRecorder extends ViewModel {
mediaRecorder.release();
recording = false;
if (listener != null) {
listener.encodingFinished(tempFile);
listener.encodingFinished(uri);
}
}
@ -78,6 +91,6 @@ public class AACRecorder extends ViewModel {
public interface AACRecorderCallbacks {
void encodingFinished(String path);
void encodingFinished(Uri uri);
}
}

@ -1,36 +1,39 @@
package org.tasks.activities;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import androidx.core.content.FileProvider;
import android.widget.Toast;
import com.todoroo.astrid.utility.Constants;
import org.tasks.files.FileHelper;
import org.tasks.injection.ActivityComponent;
import org.tasks.injection.InjectingAppCompatActivity;
import org.tasks.preferences.Preferences;
import org.tasks.time.DateTime;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
import org.tasks.R;
import org.tasks.injection.ActivityComponent;
import org.tasks.injection.InjectingAppCompatActivity;
import org.tasks.preferences.Preferences;
import timber.log.Timber;
import androidx.core.content.FileProvider;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop;
public class CameraActivity extends InjectingAppCompatActivity {
public static final String EXTRA_URI = "extra_uri";
private static final int REQUEST_CODE_CAMERA = 75;
private static final String EXTRA_OUTPUT = "extra_output";
private static final String EXTRA_URI = "extra_output";
@Inject Preferences preferences;
private File output;
private Uri uri;
@SuppressLint("NewApi")
@Override
@ -38,15 +41,27 @@ public class CameraActivity extends InjectingAppCompatActivity {
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
output = (File) savedInstanceState.getSerializable(EXTRA_OUTPUT);
} else {
output = getFilename(".jpeg");
if (output == null) {
Toast.makeText(this, R.string.external_storage_unavailable, Toast.LENGTH_LONG).show();
uri = savedInstanceState.getParcelable(EXTRA_URI);
} else {
try {
uri =
FileHelper.newFile(
this,
preferences.getCacheDirectory(),
"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);
Uri uri = FileProvider.getUriForFile(this, Constants.FILE_PROVIDER_AUTHORITY, output);
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
intent.putExtra(
MediaStore.EXTRA_OUTPUT,
FileProvider.getUriForFile(
this, Constants.FILE_PROVIDER_AUTHORITY, new File(uri.getPath())));
if (atLeastLollipop()) {
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} else {
@ -59,10 +74,10 @@ public class CameraActivity extends InjectingAppCompatActivity {
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
}
startActivityForResult(intent, REQUEST_CODE_CAMERA);
}
}
}
@Override
public void inject(ActivityComponent component) {
@ -73,13 +88,10 @@ public class CameraActivity extends InjectingAppCompatActivity {
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_CAMERA) {
if (resultCode == RESULT_OK) {
if (output != null) {
final Uri uri = Uri.fromFile(output);
Intent intent = new Intent();
intent.putExtra(EXTRA_URI, uri);
intent.setData(uri);
setResult(RESULT_OK, intent);
}
}
finish();
} else {
super.onActivityResult(requestCode, resultCode, data);
@ -90,25 +102,6 @@ public class CameraActivity extends InjectingAppCompatActivity {
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(EXTRA_OUTPUT, output);
}
private File getFilename(String extension) {
AtomicReference<String> nameRef = new AtomicReference<>();
if (!extension.startsWith(".")) {
extension = "." + extension;
}
try {
String path = preferences.getNewAttachmentPath(extension, nameRef);
File file = new File(path);
file.getParentFile().mkdirs();
if (!file.createNewFile()) {
throw new RuntimeException("Failed to create " + file.getPath());
}
return file;
} catch (IOException e) {
Timber.e(e);
}
return null;
outState.putParcelable(EXTRA_URI, uri);
}
}

@ -7,6 +7,7 @@ import android.net.Uri;
import android.os.Handler;
import android.widget.Toast;
import com.google.common.io.Files;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.todoroo.andlib.utility.DialogUtilities;
@ -138,7 +139,8 @@ public class TasksJsonExporter {
List<Task> tasks = taskDao.getAll();
if (tasks.size() > 0) {
Uri uri = FileHelper.newFile(context, backupDirectory, "application/json", filename);
String basename = Files.getNameWithoutExtension(filename);
Uri uri = FileHelper.newFile(context, backupDirectory, "application/json", basename, ".json");
OutputStream os = context.getContentResolver().openOutputStream(uri);
doTasksExport(os, tasks);
os.close();

@ -40,6 +40,7 @@ import org.tasks.data.UserActivity;
import org.tasks.data.UserActivityDao;
import org.tasks.dialogs.DialogBuilder;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@ -48,8 +49,6 @@ import javax.inject.Inject;
import timber.log.Timber;
import static org.tasks.files.FileHelper.fromUri;
public class TasksJsonImporter {
private final TagDataDao tagDataDao;
@ -120,12 +119,18 @@ public class TasksJsonImporter {
private void performImport() {
Gson gson = new Gson();
InputStream is = fromUri(activity, this.input);
InputStream is;
try {
is = activity.getContentResolver().openInputStream(this.input);
} catch (FileNotFoundException e) {
throw new IllegalStateException(e);
}
InputStreamReader reader = new InputStreamReader(is);
JsonObject input = gson.fromJson(reader, JsonObject.class);
try {
JsonElement data = input.get("data");
int version = input.get("version").getAsInt();
BackupContainer backupContainer = gson.fromJson(data, BackupContainer.class);
for (TagData tagData : backupContainer.getTags()) {
if (tagDataDao.getByUuid(tagData.getRemoteId()) == null) {
@ -174,6 +179,9 @@ public class TasksJsonImporter {
}
for (UserActivity comment : backup.comments) {
comment.setTargetId(taskUuid);
if (version < 546) {
comment.convertPictureUri();
}
userActivityDao.createNew(comment);
}
for (GoogleTask googleTask : backup.google) {
@ -191,6 +199,9 @@ public class TasksJsonImporter {
}
for (TaskAttachment attachment : backup.getAttachments()) {
attachment.setTaskId(taskUuid);
if (version < 546) {
attachment.convertPathUri();
}
taskAttachmentDao.insert(attachment);
}
for (CaldavTask caldavTask : backup.getCaldavTasks()) {

@ -1,12 +1,18 @@
package org.tasks.data;
import android.net.Uri;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import com.google.common.base.Strings;
import com.todoroo.andlib.data.Property;
import com.todoroo.andlib.data.Table;
import com.todoroo.astrid.data.Task;
import java.io.File;
@Entity(tableName = "task_attachments")
public final class TaskAttachment {
@ -16,11 +22,6 @@ public final class TaskAttachment {
public static final Property.LongProperty ID = new Property.LongProperty(TABLE, "_id");
/** default directory for files on external storage */
public static final String FILES_DIRECTORY_DEFAULT = "attachments"; // $NON-NLS-1$
/** Constants for file types */
public static final String FILE_TYPE_AUDIO = "audio/"; // $NON-NLS-1$
public static final String FILE_TYPE_IMAGE = "image/"; // $NON-NLS-1$
public static final String FILE_TYPE_OTHER = "application/octet-stream"; // $NON-NLS-1$
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "_id")
@ -37,18 +38,16 @@ public final class TaskAttachment {
private String name = "";
@ColumnInfo(name = "path")
private String path = "";
private String uri = "";
@ColumnInfo(name = "content_type")
private String contentType = "";
public static TaskAttachment createNewAttachment(
String taskUuid, String filePath, String fileName, String fileType) {
public static TaskAttachment createNewAttachment(String taskUuid, Uri uri, String fileName) {
TaskAttachment attachment = new TaskAttachment();
attachment.setTaskId(taskUuid);
attachment.setName(fileName);
attachment.setPath(filePath);
attachment.setContentType(fileType);
attachment.setUri(uri);
return attachment;
}
@ -84,12 +83,12 @@ public final class TaskAttachment {
this.name = name;
}
public String getPath() {
return path;
public String getUri() {
return uri;
}
public void setPath(String path) {
this.path = path;
public void setUri(String uri) {
this.uri = uri;
}
public String getContentType() {
@ -99,4 +98,16 @@ public final class TaskAttachment {
public void setContentType(String contentType) {
this.contentType = contentType;
}
public void convertPathUri() {
setUri(Uri.fromFile(new File(uri)).toString());
}
public void setUri(Uri uri) {
setUri(uri == null ? null : uri.toString());
}
public Uri parseUri() {
return Strings.isNullOrEmpty(uri) ? null : Uri.parse(uri);
}
}

@ -16,6 +16,9 @@ public abstract class TaskAttachmentDao {
@Query("SELECT * FROM task_attachments WHERE task_id = :taskUuid")
public abstract List<TaskAttachment> getAttachments(String taskUuid);
@Query("SELECT * FROM task_attachments")
public abstract List<TaskAttachment> getAttachments();
@Delete
public abstract void delete(TaskAttachment taskAttachment);

@ -1,17 +1,22 @@
package org.tasks.data;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import com.google.common.base.Strings;
import com.todoroo.astrid.data.Task;
import java.io.File;
import org.json.JSONException;
import org.json.JSONObject;
import org.tasks.backup.XmlReader;
import java.io.File;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import timber.log.Timber;
@Entity(tableName = "userActivity")
@ -55,7 +60,10 @@ public class UserActivity implements Parcelable {
public UserActivity(XmlReader reader) {
reader.readString("remoteId", this::setRemoteId);
reader.readString("message", this::setMessage);
reader.readString("picture", this::setPicture);
reader.readString("picture", p -> {
setPicture(p);
convertPictureUri();
});
reader.readString("target_id", this::setTargetId);
reader.readLong("created_at", this::setCreated);
}
@ -70,28 +78,6 @@ public class UserActivity implements Parcelable {
created = parcel.readLong();
}
private static Uri getPictureUri(String value) {
try {
if (value == null) {
return null;
}
if (value.contains("uri") || value.contains("path")) {
JSONObject json = new JSONObject(value);
if (json.has("uri")) {
return Uri.parse(json.getString("uri"));
}
if (json.has("path")) {
String path = json.getString("path");
return Uri.fromFile(new File(path));
}
}
return null;
} catch (JSONException e) {
Timber.e(e);
return null;
}
}
public Long getId() {
return id;
}
@ -120,6 +106,10 @@ public class UserActivity implements Parcelable {
return picture;
}
public void setPicture(Uri uri) {
picture = uri == null ? null : uri.toString();
}
public void setPicture(String picture) {
this.picture = picture;
}
@ -141,7 +131,33 @@ public class UserActivity implements Parcelable {
}
public Uri getPictureUri() {
return getPictureUri(picture);
return Strings.isNullOrEmpty(picture) ? null : Uri.parse(picture);
}
public void convertPictureUri() {
setPicture(getLegacyPictureUri(picture));
}
private static Uri getLegacyPictureUri(String value) {
try {
if (Strings.isNullOrEmpty(value)) {
return null;
}
if (value.contains("uri") || value.contains("path")) {
JSONObject json = new JSONObject(value);
if (json.has("uri")) {
return Uri.parse(json.getString("uri"));
}
if (json.has("path")) {
String path = json.getString("path");
return Uri.fromFile(new File(path));
}
}
return null;
} catch (JSONException e) {
Timber.e(e);
return null;
}
}
@Override

@ -3,6 +3,8 @@ package org.tasks.data;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;
import com.todoroo.andlib.utility.DateUtilities;
import com.todoroo.astrid.data.Task;
import com.todoroo.astrid.helper.UUIDHelper;
@ -14,9 +16,15 @@ public abstract class UserActivityDao {
@Insert
public abstract void insert(UserActivity userActivity);
@Update
public abstract void update(UserActivity userActivity);
@Query("SELECT * FROM userActivity WHERE target_id = :taskUuid ORDER BY created_at DESC ")
public abstract List<UserActivity> getCommentsForTask(String taskUuid);
@Query("SELECT * FROM userActivity")
public abstract List<UserActivity> getComments();
public void createNew(UserActivity item) {
if (item.getCreated() == null || item.getCreated() == 0L) {
item.setCreated(DateUtilities.now());

@ -1,26 +1,31 @@
package org.tasks.dialogs;
import static com.google.common.collect.Lists.newArrayList;
import static org.tasks.dialogs.RecordAudioDialog.newRecordAudioDialog;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.provider.MediaStore.Images.Media;
import androidx.annotation.NonNull;
import com.todoroo.astrid.files.FilesControlSet;
import java.util.List;
import javax.inject.Inject;
import org.tasks.R;
import org.tasks.activities.CameraActivity;
import org.tasks.files.FileExplore;
import org.tasks.injection.DialogFragmentComponent;
import org.tasks.injection.ForActivity;
import org.tasks.injection.InjectingDialogFragment;
import org.tasks.preferences.Device;
import org.tasks.preferences.Preferences;
import java.util.List;
import javax.inject.Inject;
import androidx.annotation.NonNull;
import static com.google.common.collect.Lists.newArrayList;
import static org.tasks.dialogs.RecordAudioDialog.newRecordAudioDialog;
import static org.tasks.files.FileHelper.newFilePickerIntent;
public class AddAttachmentDialog extends InjectingDialogFragment {
private static final String FRAG_TAG_RECORD_AUDIO = "frag_tag_record_audio";
@ -29,9 +34,6 @@ public class AddAttachmentDialog extends InjectingDialogFragment {
public static final int REQUEST_STORAGE = 12122;
public static final int REQUEST_AUDIO = 12123;
public static final String EXTRA_PATH = "extra_path";
public static final String EXTRA_TYPE = "extra_type";
@Inject @ForActivity Context context;
@Inject DialogBuilder dialogBuilder;
@Inject Device device;
@ -88,7 +90,7 @@ public class AddAttachmentDialog extends InjectingDialogFragment {
}
}
public void pickFromStorage() {
getTargetFragment().startActivityForResult(new Intent(context, FileExplore.class), REQUEST_STORAGE);
private void pickFromStorage() {
getTargetFragment().startActivityForResult(newFilePickerIntent(getActivity(), null), REQUEST_STORAGE);
}
}

@ -2,13 +2,12 @@ package org.tasks.dialogs;
import static android.app.Activity.RESULT_OK;
import static org.tasks.PermissionUtil.verifyPermissions;
import static org.tasks.dialogs.AddAttachmentDialog.EXTRA_PATH;
import static org.tasks.dialogs.AddAttachmentDialog.EXTRA_TYPE;
import android.app.Dialog;
import androidx.lifecycle.ViewModelProviders;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
@ -22,7 +21,6 @@ import com.todoroo.astrid.files.FilesControlSet;
import com.todoroo.astrid.voice.AACRecorder;
import javax.inject.Inject;
import org.tasks.R;
import org.tasks.data.TaskAttachment;
import org.tasks.injection.DialogFragmentComponent;
import org.tasks.injection.InjectingDialogFragment;
import org.tasks.preferences.FragmentPermissionRequestor;
@ -31,6 +29,8 @@ import org.tasks.preferences.PermissionRequestor;
import org.tasks.preferences.Preferences;
import org.tasks.themes.Theme;
import java.io.IOException;
public class RecordAudioDialog extends InjectingDialogFragment
implements AACRecorder.AACRecorderCallbacks {
@ -45,7 +45,7 @@ public class RecordAudioDialog extends InjectingDialogFragment
private AACRecorder recorder;
public static RecordAudioDialog newRecordAudioDialog(FilesControlSet target, int requestCode) {
static RecordAudioDialog newRecordAudioDialog(FilesControlSet target, int requestCode) {
RecordAudioDialog dialog = new RecordAudioDialog();
dialog.setTargetFragment(target, requestCode);
return dialog;
@ -75,9 +75,13 @@ public class RecordAudioDialog extends InjectingDialogFragment
}
private void startRecording() {
recorder.startRecording();
try {
recorder.startRecording(getContext());
timer.setBase(recorder.getBase());
timer.start();
} catch (IOException e) {
stopRecording();
}
}
@Override
@ -99,11 +103,9 @@ public class RecordAudioDialog extends InjectingDialogFragment
}
@Override
public void encodingFinished(String path) {
final String extension = path.substring(path.lastIndexOf('.') + 1);
public void encodingFinished(Uri uri) {
Intent intent = new Intent();
intent.putExtra(EXTRA_PATH, path);
intent.putExtra(EXTRA_TYPE, TaskAttachment.FILE_TYPE_AUDIO + extension);
intent.setData(uri);
Fragment target = getTargetFragment();
if (target != null) {
target.onActivityResult(getTargetRequestCode(), RESULT_OK, intent);

@ -1,30 +1,23 @@
package org.tasks.files;
import static org.tasks.PermissionUtil.verifyPermissions;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import androidx.annotation.NonNull;
import com.google.common.base.Strings;
import com.nononsenseapps.filepicker.FilePickerActivity;
import java.io.File;
import javax.inject.Inject;
import org.tasks.injection.ActivityComponent;
import org.tasks.injection.InjectingAppCompatActivity;
import org.tasks.preferences.ActivityPermissionRequestor;
import org.tasks.preferences.PermissionRequestor;
import java.io.File;
public class FileExplore extends InjectingAppCompatActivity {
public static final String EXTRA_FILE = "extra_file"; // $NON-NLS-1$
public static final String EXTRA_DIRECTORY = "extra_directory"; // $NON-NLS-1$
public static final String EXTRA_START_PATH = "extra_start_path";
public static final String EXTRA_DIRECTORY_MODE = "extra_directory_mode"; // $NON-NLS-1$
private static final int REQUEST_PICKER = 1000;
@Inject ActivityPermissionRequestor permissionRequestor;
private boolean directoryMode;
private String startPath;
@ -38,11 +31,9 @@ public class FileExplore extends InjectingAppCompatActivity {
directoryMode = intent.getBooleanExtra(EXTRA_DIRECTORY_MODE, false);
startPath = intent.getStringExtra(EXTRA_START_PATH);
if (permissionRequestor.requestFileWritePermission()) {
launchPicker();
}
}
}
@Override
public void inject(ActivityComponent component) {
@ -71,29 +62,12 @@ public class FileExplore extends InjectingAppCompatActivity {
startActivityForResult(i, REQUEST_PICKER);
}
@Override
public void onRequestPermissionsResult(
int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == PermissionRequestor.REQUEST_FILE_WRITE) {
if (verifyPermissions(grantResults)) {
launchPicker();
} else {
finish();
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_PICKER) {
if (resultCode == Activity.RESULT_OK) {
Uri uri = data.getData();
File file = com.nononsenseapps.filepicker.Utils.getFileForUri(uri);
Intent intent = new Intent();
intent.putExtra(directoryMode ? EXTRA_DIRECTORY : EXTRA_FILE, file.getAbsolutePath());
intent.setData(uri);
intent.setData(data.getData());
setResult(Activity.RESULT_OK, intent);
}
finish();

@ -1,38 +1,43 @@
package org.tasks.files;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.webkit.MimeTypeMap;
import android.widget.Toast;
import com.google.common.io.ByteStreams;
import com.google.common.io.Files;
import com.todoroo.astrid.utility.Constants;
import org.tasks.preferences.BasicPreferences;
import org.tasks.preferences.Preferences;
import org.tasks.R;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Nullable;
import androidx.core.content.FileProvider;
import androidx.documentfile.provider.DocumentFile;
import timber.log.Timber;
import static androidx.core.content.FileProvider.getUriForFile;
import static com.google.common.collect.Iterables.any;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastKitKat;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop;
public class FileHelper {
public static void newFilePicker(Activity activity, int rc, @Nullable Uri initial, String... mimeTypes) {
public static Intent newFilePickerIntent(Activity activity, Uri initial, String... mimeTypes) {
if (atLeastKitKat()) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
@ -47,7 +52,7 @@ public class FileHelper {
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
}
}
activity.startActivityForResult(intent, rc);
return intent;
} else {
Intent intent = new Intent(activity, FileExplore.class);
if (initial != null) {
@ -55,7 +60,7 @@ public class FileHelper {
FileExplore.EXTRA_START_PATH,
new File(initial.getPath()));
}
activity.startActivityForResult(intent, rc);
return intent;
}
}
@ -65,7 +70,8 @@ public class FileHelper {
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_READ_URI_PERMISSION);
| Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
intent.putExtra("android.content.extra.SHOW_ADVANCED",true);
activity.startActivityForResult(intent, rc);
} else {
@ -80,48 +86,61 @@ public class FileHelper {
}
}
public static InputStream fromUri(Context context, Uri uri) {
try {
public static void delete(Context context, Uri uri) {
if (uri == null) {
return;
}
switch (uri.getScheme()) {
case "content":
return context.getContentResolver().openInputStream(uri);
DocumentFile documentFile = DocumentFile.fromSingleUri(context, uri);
documentFile.delete();
break;
case "file":
return new FileInputStream(new File(uri.getPath()));
default:
throw new IllegalArgumentException("Unhandled scheme: " + uri.getScheme());
}
} catch (FileNotFoundException e) {
Timber.e(e);
return null;
new File(uri.getPath()).delete();
break;
}
}
public static String getPathFromUri(Activity activity, Uri uri) {
String[] projection = {MediaStore.Images.Media.DATA};
Cursor cursor = activity.managedQuery(uri, projection, null, null, null);
if (cursor != null) {
int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
cursor.moveToFirst();
return cursor.getString(column_index);
} else {
return uri.getPath();
public static String getFilename(Context context, Uri uri) {
switch (uri.getScheme()) {
case ContentResolver.SCHEME_FILE:
return uri.getLastPathSegment();
case ContentResolver.SCHEME_CONTENT:
Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
try {
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
} finally {
cursor.close();
}
}
break;
}
return null;
}
public static Intent getReadableActionView(Context context, String path, String type) {
public static void startActionView(Activity context, Uri uri) {
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
String filename = getFilename(context, uri);
String extension = Files.getFileExtension(filename);
String mimeType = mimeTypeMap.getMimeTypeFromExtension(extension);
Intent intent = new Intent(Intent.ACTION_VIEW);
Uri uri =
FileProvider.getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, new File(path));
intent.setDataAndType(uri, type);
grantReadPermissions(context, intent, uri);
return intent;
if (uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
uri = copyToUri(context, Uri.fromFile(context.getCacheDir()), uri);
}
Uri share = getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, new File(uri.getPath()));
intent.setDataAndType(share, mimeType);
grantReadPermissions(context, intent, share);
PackageManager packageManager = context.getPackageManager();
if (intent.resolveActivity(packageManager) != null) {
context.startActivity(intent);
} else {
Toast.makeText(context, R.string.no_application_found, Toast.LENGTH_SHORT).show();
}
}
public static void grantReadPermissions(Context context, Intent intent, Uri uri) {
if (atLeastLollipop()) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
private static void grantReadPermissions(Context context, Intent intent, Uri uri) {
if (atLeastLollipop()) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
@ -135,10 +154,10 @@ public class FileHelper {
}
}
}
}
public static Uri newFile(Context context, Uri destination, String mimeType, String filename)
public static Uri newFile(Context context, Uri destination, String mimeType, String baseName, String extension)
throws IOException {
String filename = getNonCollidingFileName(context, destination, baseName, extension);
switch (destination.getScheme()) {
case "content":
DocumentFile tree = DocumentFile.fromTreeUri(context, destination);
@ -161,4 +180,56 @@ public class FileHelper {
throw new IllegalArgumentException("Unknown URI scheme: " + destination.getScheme());
}
}
public static Uri copyToUri(Context context, Uri destination, Uri input) {
ContentResolver contentResolver = context.getContentResolver();
MimeTypeMap mime = MimeTypeMap.getSingleton();
String filename = getFilename(context, input);
String baseName = Files.getNameWithoutExtension(filename);
String extension = Files.getFileExtension(filename);
String mimeType = mime.getMimeTypeFromExtension(extension);
try {
Uri output = newFile(context, destination, mimeType, baseName, extension);
InputStream inputStream = contentResolver.openInputStream(input);
OutputStream outputStream = contentResolver.openOutputStream(output);
ByteStreams.copy(inputStream, outputStream);
inputStream.close();
outputStream.close();
return output;
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
private static String getNonCollidingFileName(Context context, Uri uri, String baseName, String extension) {
int tries = 1;
if (!extension.startsWith(".")) {
extension = "." + extension;
}
String tempName = baseName;
switch (uri.getScheme()) {
case ContentResolver.SCHEME_CONTENT:
DocumentFile dir = DocumentFile.fromTreeUri(context, uri);
List<DocumentFile> documentFiles = Arrays.asList(dir.listFiles());
while (true) {
String result = tempName + extension;
if (any(documentFiles, f -> f.getName().equals(result))) {
tempName = baseName + "-" + tries;
tries++;
} else {
break;
}
}
break;
case ContentResolver.SCHEME_FILE:
File f = new File(uri.getPath(), baseName + extension);
while (f.exists()) {
tempName = baseName + "-" + tries; // $NON-NLS-1$
f = new File(uri.getPath(), tempName + extension);
tries++;
}
break;
}
return tempName + extension;
}
}

@ -1,7 +1,16 @@
package org.tasks.files;
import android.content.ContentResolver;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import timber.log.Timber;
public class ImageHelper {
@ -27,17 +36,45 @@ public class ImageHelper {
return inSampleSize;
}
public static Bitmap sampleBitmap(String path, int reqWidth, int reqHeight) {
public static Bitmap sampleBitmap(Context context, Uri uri, int reqWidth, int reqHeight) {
ContentResolver contentResolver = context.getContentResolver();
InputStream inputStream;
try {
inputStream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
Timber.e(e);
return null;
}
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, options);
BitmapFactory.decodeStream(inputStream, null, options);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFile(path, options);
try {
inputStream.close();
} catch (IOException e) {
Timber.e(e);
}
try {
inputStream = contentResolver.openInputStream(uri);
return BitmapFactory.decodeStream(inputStream, null, options);
} catch (IOException e) {
Timber.e(e);
return null;
} finally {
try {
inputStream.close();
} catch (IOException e) {
Timber.e(e);
}
}
}
}

@ -1,8 +1,5 @@
package org.tasks.fragments;
import static org.tasks.files.FileHelper.getPathFromUri;
import static org.tasks.files.ImageHelper.sampleBitmap;
import android.app.Activity;
import android.content.DialogInterface;
import android.content.Intent;
@ -10,9 +7,6 @@ import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.KeyEvent;
@ -23,19 +17,11 @@ import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import butterknife.OnEditorAction;
import butterknife.OnTextChanged;
import com.google.common.base.Strings;
import com.todoroo.andlib.utility.AndroidUtilities;
import com.todoroo.astrid.data.Task;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import org.json.JSONException;
import org.json.JSONObject;
import org.tasks.R;
import org.tasks.activities.CameraActivity;
import org.tasks.dialogs.DialogBuilder;
@ -43,7 +29,22 @@ import org.tasks.injection.FragmentComponent;
import org.tasks.preferences.Device;
import org.tasks.preferences.Preferences;
import org.tasks.ui.TaskEditControlFragment;
import timber.log.Timber;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import butterknife.OnEditorAction;
import butterknife.OnTextChanged;
import static org.tasks.files.ImageHelper.sampleBitmap;
public class CommentBarFragment extends TaskEditControlFragment {
@ -71,17 +72,6 @@ public class CommentBarFragment extends TaskEditControlFragment {
private CommentBarFragmentCallback callback;
private Uri pendingCommentPicture = null;
private static JSONObject savePictureJson(final Uri uri) {
try {
JSONObject json = new JSONObject();
json.put("uri", uri.toString());
return json;
} catch (JSONException e) {
Timber.e(e);
}
return null;
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
@ -191,7 +181,7 @@ public class CommentBarFragment extends TaskEditControlFragment {
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_CAMERA) {
if (resultCode == Activity.RESULT_OK) {
pendingCommentPicture = data.getParcelableExtra(CameraActivity.EXTRA_URI);
pendingCommentPicture = data.getData();
setPictureButtonToPendingPicture();
commentField.requestFocus();
}
@ -206,10 +196,12 @@ public class CommentBarFragment extends TaskEditControlFragment {
}
private void setPictureButtonToPendingPicture() {
String path = getPathFromUri(activity, pendingCommentPicture);
Bitmap bitmap =
sampleBitmap(
path, pictureButton.getLayoutParams().width, pictureButton.getLayoutParams().height);
activity,
pendingCommentPicture,
pictureButton.getLayoutParams().width,
pictureButton.getLayoutParams().height);
pictureButton.setImageBitmap(bitmap);
commentButton.setVisibility(View.VISIBLE);
}
@ -219,13 +211,7 @@ public class CommentBarFragment extends TaskEditControlFragment {
if (TextUtils.isEmpty(message)) {
message = " ";
}
String picture = null;
if (pendingCommentPicture != null) {
JSONObject pictureJson = savePictureJson(pendingCommentPicture);
if (pictureJson != null) {
picture = pictureJson.toString();
}
}
Uri picture = pendingCommentPicture;
if (commentField != null) {
commentField.setText(""); // $NON-NLS-1$
@ -281,7 +267,7 @@ public class CommentBarFragment extends TaskEditControlFragment {
public interface CommentBarFragmentCallback {
void addComment(String message, String picture);
void addComment(String message, Uri picture);
}
interface ClearImageCallback {

@ -42,6 +42,7 @@ import static com.todoroo.andlib.utility.AndroidUtilities.atLeastKitKat;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop;
import static org.tasks.dialogs.ExportTasksDialog.newExportTasksDialog;
import static org.tasks.dialogs.ImportTasksDialog.newImportTasksDialog;
import static org.tasks.files.FileHelper.newFilePickerIntent;
import static org.tasks.locale.LocalePickerDialog.newLocalePickerDialog;
import static org.tasks.themes.ThemeColor.LAUNCHERS;
@ -154,7 +155,7 @@ public class BasicPreferences extends InjectingPreferenceActivity
findPreference(R.string.backup_BAc_import)
.setOnPreferenceClickListener(
preference -> {
FileHelper.newFilePicker(BasicPreferences.this, REQUEST_PICKER, preferences.getBackupDirectory());
startActivityForResult(newFilePickerIntent(BasicPreferences.this, preferences.getBackupDirectory()), REQUEST_PICKER);
return false;
});
@ -235,14 +236,14 @@ public class BasicPreferences extends InjectingPreferenceActivity
}
} else if (requestCode == REQUEST_CODE_BACKUP_DIR) {
if (resultCode == RESULT_OK && data != null) {
Uri dir = data.getData();
Uri uri = data.getData();
if (atLeastLollipop()) {
getContentResolver()
.takePersistableUriPermission(
dir,
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
preferences.setString(R.string.p_backup_dir, dir.toString());
preferences.setUri(R.string.p_backup_dir, uri);
updateBackupDirectory();
}
} else if (requestCode == REQUEST_PICKER) {

@ -1,8 +1,10 @@
package org.tasks.preferences;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop;
import static org.tasks.PermissionUtil.verifyPermissions;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.preference.CheckBoxPreference;
import android.speech.tts.TextToSpeech;
@ -12,6 +14,7 @@ import java.io.File;
import javax.inject.Inject;
import org.tasks.R;
import org.tasks.files.FileExplore;
import org.tasks.files.FileHelper;
import org.tasks.injection.ActivityComponent;
import org.tasks.injection.InjectingPreferenceActivity;
import org.tasks.scheduling.CalendarNotificationIntentService;
@ -45,14 +48,19 @@ public class MiscellaneousPreferences extends InjectingPreferenceActivity {
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_FILES_DIR && resultCode == RESULT_OK) {
if (data != null) {
String dir = data.getStringExtra(FileExplore.EXTRA_DIRECTORY);
preferences.setString(R.string.p_attachment_dir, dir);
if (requestCode == REQUEST_CODE_FILES_DIR) {
if (resultCode == RESULT_OK) {
Uri uri = data.getData();
if (atLeastLollipop()) {
getContentResolver()
.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
preferences.setUri(R.string.p_attachment_dir, uri);
updateAttachmentDirectory();
}
return;
}
} else {
try {
if (requestCode == REQUEST_CODE_TTS_CHECK) {
if (resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_PASS) {
@ -71,6 +79,7 @@ public class MiscellaneousPreferences extends InjectingPreferenceActivity {
}
super.onActivityResult(requestCode, resultCode, data);
}
}
@Override
protected void onDestroy() {
@ -83,10 +92,8 @@ public class MiscellaneousPreferences extends InjectingPreferenceActivity {
findPreference(getString(R.string.p_attachment_dir))
.setOnPreferenceClickListener(
p -> {
Intent filesDir = new Intent(MiscellaneousPreferences.this, FileExplore.class);
filesDir.putExtra(FileExplore.EXTRA_DIRECTORY_MODE, true);
startActivityForResult(filesDir, REQUEST_CODE_FILES_DIR);
return true;
FileHelper.newDirectoryPicker(this, REQUEST_CODE_FILES_DIR, preferences.getAttachmentsDirectory());
return false;
});
updateAttachmentDirectory();
}
@ -96,8 +103,10 @@ public class MiscellaneousPreferences extends InjectingPreferenceActivity {
}
private String getAttachmentDirectory() {
File dir = preferences.getAttachmentsDirectory();
return dir == null ? "" : dir.getAbsolutePath();
Uri uri = preferences.getAttachmentsDirectory();
return uri.getScheme().equals("file")
? new File(uri.getPath()).getAbsolutePath()
: uri.toString();
}
private void initializeCalendarReminderPreference() {

@ -27,10 +27,6 @@ public class PermissionChecker {
asList(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR));
}
public boolean canWriteToExternalStorage() {
return checkPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
public boolean canAccessAccounts() {
return atLeastOreo() || checkPermission(Manifest.permission.GET_ACCOUNTS);
}

@ -4,7 +4,6 @@ import android.Manifest;
public abstract class PermissionRequestor {
public static final int REQUEST_FILE_WRITE = 50;
public static final int REQUEST_CALENDAR = 51;
public static final int REQUEST_MIC = 52;
public static final int REQUEST_GOOGLE_ACCOUNTS = 53;
@ -24,14 +23,6 @@ public abstract class PermissionRequestor {
return false;
}
public boolean requestFileWritePermission() {
if (permissionChecker.canWriteToExternalStorage()) {
return true;
}
requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, REQUEST_FILE_WRITE);
return false;
}
public boolean requestCalendarPermissions() {
return requestCalendarPermissions(REQUEST_CALENDAR);
}

@ -28,7 +28,6 @@ import org.tasks.time.DateTime;
import java.io.File;
import java.util.Collection;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
@ -49,31 +48,17 @@ public class Preferences {
private static final String PREF_SORT_SORT = "sort_sort"; // $NON-NLS-1$
private final Context context;
private final PermissionChecker permissionChecker;
private final SharedPreferences prefs;
private final SharedPreferences publicPrefs;
@Inject
public Preferences(@ForApplication Context context, PermissionChecker permissionChecker) {
public Preferences(@ForApplication Context context) {
this.context = context;
this.permissionChecker = permissionChecker;
prefs = PreferenceManager.getDefaultSharedPreferences(context);
publicPrefs =
context.getSharedPreferences(AstridApiConstants.PUBLIC_PREFS, Context.MODE_PRIVATE);
}
private static String getNonCollidingFileName(String dir, String baseName, String extension) {
int tries = 1;
File f = new File(dir + File.separator + baseName + extension);
String tempName = baseName;
while (f.exists()) {
tempName = baseName + "-" + tries; // $NON-NLS-1$
f = new File(dir + File.separator + tempName + extension);
tries++;
}
return tempName + extension;
}
public boolean backButtonSavesTask() {
return getBoolean(R.string.p_back_button_saves_task, false);
}
@ -389,18 +374,34 @@ public class Preferences {
}
}
public File getAttachmentsDirectory() {
File directory = null;
String customDir = getStringValue(R.string.p_attachment_dir);
if (permissionChecker.canWriteToExternalStorage() && !TextUtils.isEmpty(customDir)) {
directory = new File(customDir);
public Uri getAttachmentsDirectory() {
Uri uri = getUri(R.string.p_attachment_dir);
if (uri != null) {
switch (uri.getScheme()) {
case "file":
File file = new File(uri.getPath());
try {
if (file.canWrite()) {
return uri;
}
} catch (SecurityException ignored) {
}
break;
case "content":
if (hasWritePermission(context, uri)) {
return uri;
}
break;
}
if (directory == null || !directory.exists()) {
directory = getDefaultFileLocation(TaskAttachment.FILES_DIRECTORY_DEFAULT);
}
return directory;
if (atLeastKitKat()) {
return DocumentFile.fromFile(context.getExternalFilesDir(null))
.createDirectory(TaskAttachment.FILES_DIRECTORY_DEFAULT)
.getUri();
} else {
return Uri.fromFile(getDefaultFileLocation(TaskAttachment.FILES_DIRECTORY_DEFAULT));
}
}
private File getDefaultFileLocation(String type) {
@ -413,20 +414,12 @@ public class Preferences {
return file.isDirectory() || file.mkdirs() ? file : null;
}
public String getNewAudioAttachmentPath(AtomicReference<String> nameReference) {
return getNewAttachmentPath(".m4a", nameReference); // $NON-NLS-1$
}
public String getNewAttachmentPath(String extension, AtomicReference<String> nameReference) {
String dir = getAttachmentsDirectory().getAbsolutePath();
String name = getNonCollidingFileName(dir, new DateTime().toString("yyyyMMddHHmm"), extension);
if (nameReference != null) {
nameReference.set(name);
public Uri getCacheDirectory() {
if (atLeastKitKat()) {
return DocumentFile.fromFile(context.getCacheDir()).getUri();
} else {
return Uri.fromFile(context.getCacheDir());
}
return dir + File.separator + name;
}
public Uri getBackupDirectory() {

@ -168,8 +168,6 @@
<string name="premium_remove_file_confirm">Сигурни ли сте? Не може да бъде отменено</string>
<string name="audio_recording_title">Записване на Аудио</string>
<string name="audio_stop_recording">Спри Записването</string>
<string name="file_type_unhandled">Съжаляваме! Не е намерено приложение, което да работи с този тип файл.</string>
<string name="file_err_copy">Грешка при копиране на файла за прикачване</string>
<string name="ring_once">Звънене веднъж</string>
<string name="ring_five_times">Звънене пет пъти</string>
<string name="ring_nonstop">Звънене без спиране</string>
@ -289,7 +287,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks ще изтоваря имената на задачите по време на напомняния за задача</string>
<string name="delete_task">Изтрий задача</string>
<string name="voice_command_added_task">Добавена задача</string>
<string name="external_storage_unavailable">Не може да достъпвате до външната памет</string>
<plurals name="Ntasks">
<item quantity="one">1 задача</item>
<item quantity="other">%d задачи</item>

@ -144,8 +144,6 @@
<string name="premium_remove_file_confirm">Skutečně? Nebude cesty zpět</string>
<string name="audio_recording_title">Nahrávám zvuk</string>
<string name="audio_stop_recording">Ukončit záznam</string>
<string name="file_type_unhandled">Pro tento typ souborů nebyla nalezena žádná aplikace.</string>
<string name="file_err_copy">Chyba při kopírování souboru jako přílohy</string>
<string name="random_reminder_hour">hodina</string>
<string name="random_reminder_day">den</string>
<string name="random_reminder_week">týden</string>
@ -182,7 +180,6 @@
<string name="EPr_voiceRemindersEnabled_title">Hlasové upomínky</string>
<string name="delete_task">Smazat úkol</string>
<string name="voice_command_added_task">Přidán úkol</string>
<string name="external_storage_unavailable">Není přístup k externí databázi</string>
<plurals name="Ntasks">
<item quantity="one">1 úkol</item>
<item quantity="other">%d úkolů</item>

@ -163,8 +163,6 @@
<string name="premium_remove_file_confirm">Sind Sie sicher? Das kann nicht rückgängig gemacht werden</string>
<string name="audio_recording_title">Audio aufnehmen</string>
<string name="audio_stop_recording">Aufnahme stoppen</string>
<string name="file_type_unhandled">Tut mir leid! Dieser Dateityp kann nicht geöffnet werden.</string>
<string name="file_err_copy">Fehler beim Kopieren der anzuhängenden Datei</string>
<string name="ring_once">Einmal klingeln</string>
<string name="ring_five_times">Fünfmal klingeln</string>
<string name="ring_nonstop">Ununterbrochen klingeln</string>
@ -283,7 +281,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks wird Aufgabennamen bei der Erinnerung aussprechen</string>
<string name="delete_task">Aufgabe löschen</string>
<string name="voice_command_added_task">Hinzugefügte Aufgabe</string>
<string name="external_storage_unavailable">Kein Zugriff auf externen Speicher</string>
<plurals name="Ntasks">
<item quantity="one">1 Aufgabe</item>
<item quantity="other">%d Aufgaben</item>

@ -128,8 +128,6 @@
<string name="premium_remove_file_confirm">Είστε σίγουρος; Δεν μπορεί να ακυρωθεί</string>
<string name="audio_recording_title">Εγγραφή Ήχου</string>
<string name="audio_stop_recording">Σταμάτημα εγγραφής</string>
<string name="file_type_unhandled">Συγγνώμη! Δεν βρέθηκε εφαρμογή που να χειρίζεται τέτοιο τύπο αρχείου</string>
<string name="file_err_copy">Σφάλμα αντιγραφής αρχείου προς επισύναψη</string>
<string name="random_reminder_hour">μια ώρα</string>
<string name="random_reminder_day">μια μέρα</string>
<string name="random_reminder_week">μια εβδομάδα</string>
@ -165,7 +163,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Η εφαρμογή θα λέει να ονόματα των εργασιών κατά την διάρκεια των υπενθυμίσεων</string>
<string name="delete_task">Διαγραφή καθήκοντος</string>
<string name="voice_command_added_task">Η εργασία προστέθηκε</string>
<string name="external_storage_unavailable">Δεν είναι δυνατή η πρόσβαση σε εξωτερικά μέσα αποθήκευσης</string>
<string name="today">Σήμερα</string>
<string name="tomorrow">Αύριο</string>
<string name="yesterday">Χθές</string>

@ -164,8 +164,6 @@
<string name="premium_remove_file_confirm">Está seguro? No se puede deshacer</string>
<string name="audio_recording_title">Grabando Audio</string>
<string name="audio_stop_recording">Detener grabación</string>
<string name="file_type_unhandled">Lo sentimos! No se encontró ninguna aplicación para abrir este tipo de archivo.</string>
<string name="file_err_copy">Error al copiar el archivo a adjuntar</string>
<string name="ring_once">Sonar una vez</string>
<string name="ring_five_times">Sonar cinco veces</string>
<string name="ring_nonstop">Sonar sin parar</string>
@ -285,7 +283,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks dirá los nombres de las tareas durante los avisos</string>
<string name="delete_task">Eliminar tarea</string>
<string name="voice_command_added_task">Tarea agregada</string>
<string name="external_storage_unavailable">Almacenamiento externo inaccesible</string>
<plurals name="Ntasks">
<item quantity="one">1 tarea</item>
<item quantity="other">%d tareas</item>

@ -113,8 +113,6 @@
<string name="premium_remove_file_confirm">آیا مطمن هستید؟ قادر به برگرداندن نخواهید بود</string>
<string name="audio_recording_title">ضبط صدا</string>
<string name="audio_stop_recording">توقف ضبط</string>
<string name="file_type_unhandled">پوزش! هیچ برنامه ای برای بازکردن این فایل پیدا نشد.</string>
<string name="file_err_copy">خطای کپی فایل برای ضمیمه</string>
<string name="ring_once">یک بار زنگ بزن</string>
<string name="ring_five_times">پنج بار زنگ بزن</string>
<string name="ring_nonstop">بدون توقف زنگ بزن</string>
@ -177,7 +175,6 @@
<string name="EPr_voiceRemindersEnabled_title">یادآور صوتی</string>
<string name="delete_task">حذف وظیفه</string>
<string name="voice_command_added_task">وظیفه اضافه شده</string>
<string name="external_storage_unavailable">عدم امکان دسترسی به حافظه خارجی</string>
<plurals name="Ntasks">
<item quantity="one">۱ وظیفه</item>
<item quantity="other">%d tasks</item>

@ -156,8 +156,6 @@
<string name="premium_remove_file_confirm">Oletko varma? Ei voi peruuttaa</string>
<string name="audio_recording_title">Tallentaa ääntä</string>
<string name="audio_stop_recording">Lopeta tallennus</string>
<string name="file_type_unhandled">Pahoittelen! Ei sopivaa sovelllusta tämän tiedostotyypin käsittelyyn.</string>
<string name="file_err_copy">Virhe tiedoston kopioimisessa liitteeseen</string>
<string name="ring_once">Soi kerran</string>
<string name="ring_five_times">Soi viisi kertaa</string>
<string name="ring_nonstop">Soi jatkuvasti</string>
@ -276,7 +274,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks kertoo tehtävien nimet muistutusten aikana</string>
<string name="delete_task">Poista tehtävä</string>
<string name="voice_command_added_task">Lisätty tehtävä</string>
<string name="external_storage_unavailable">Eii pääsyä ulkoiseen muistiin</string>
<plurals name="Ntasks">
<item quantity="one">1 tehtävä</item>
<item quantity="other">%d tehtävät</item>

@ -162,8 +162,6 @@
<string name="premium_remove_file_confirm">Êtes-vous certain(e)? Cette opération est irréversible</string>
<string name="audio_recording_title">Enregistrement Audio</string>
<string name="audio_stop_recording">Arrêter l\'enregistrement</string>
<string name="file_type_unhandled">Désolé ! Aucune application n\'a été trouvé pour gérer ce type de fichier.</string>
<string name="file_err_copy">Erreur lors de la copie du fichier à joindre</string>
<string name="ring_once">Sonner une fois</string>
<string name="ring_five_times">Sonner cinq fois</string>
<string name="ring_nonstop">Sonner en continu</string>
@ -275,7 +273,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks donnera le nom de la tâche</string>
<string name="delete_task">Supprimer la tâche ? </string>
<string name="voice_command_added_task">Tâche ajoutée</string>
<string name="external_storage_unavailable">Impossible d\'accéder au stockage externe</string>
<plurals name="Ntasks">
<item quantity="one">1 tâche</item>
<item quantity="other">%d tâches</item>

@ -152,8 +152,6 @@
<string name="premium_remove_file_confirm">Está seguro? No se puede deshacer</string>
<string name="audio_recording_title">Grabando Audio</string>
<string name="audio_stop_recording">Detener grabación</string>
<string name="file_type_unhandled">Lo sentimos! No se encontró ninguna aplicación para abrir este tipo de archivo.</string>
<string name="file_err_copy">Error al copiar el archivo a adjuntar</string>
<string name="ring_once">Sonar una vez</string>
<string name="ring_five_times">Sonar cinco veces</string>
<string name="ring_nonstop">Sonar sin parar</string>
@ -198,7 +196,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks dirá los nombres de las tareas durante los avisos</string>
<string name="delete_task">Eliminar tarefa</string>
<string name="voice_command_added_task">Tarea agregada</string>
<string name="external_storage_unavailable">Almacenamiento externo inaccesible</string>
<plurals name="Ntasks">
<item quantity="one">1 tarea</item>
<item quantity="other">%d tareas</item>

@ -168,8 +168,6 @@
<string name="premium_remove_file_confirm">Biztos benne? A művelet nem visszavonható</string>
<string name="audio_recording_title">Hang rögzítése</string>
<string name="audio_stop_recording">Hangrögzítés leállítása</string>
<string name="file_type_unhandled">Sajnáljuk, az adott file megnyitására képes alkalmazás nem található.</string>
<string name="file_err_copy">Hiba lépett fel a file csatolásakor</string>
<string name="ring_once">Egy csengés</string>
<string name="ring_five_times">Öt csengés</string>
<string name="ring_nonstop">Folyamatos csengés</string>
@ -289,7 +287,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">A Tasks kimondja a feladatcímeket emlékeztetőkor</string>
<string name="delete_task">Feladat törlése</string>
<string name="voice_command_added_task">Hozzáadott feladat</string>
<string name="external_storage_unavailable">Külső tárhely nem elérhető</string>
<plurals name="Ntasks">
<item quantity="one">1 feladat</item>
<item quantity="other">%d feladat</item>

@ -166,8 +166,6 @@
<string name="premium_remove_file_confirm">Se sicuro? Non può essere eseguito</string>
<string name="audio_recording_title">Registrazione Audio</string>
<string name="audio_stop_recording">Fine Registrazione</string>
<string name="file_type_unhandled">Spiacente! Non è stata trovata nessuna applicazione per gestire questo tipo di file</string>
<string name="file_err_copy">Errore di copia dei file da allegare</string>
<string name="ring_once">Suona una volta</string>
<string name="ring_five_times">Suona cinque volte</string>
<string name="ring_nonstop">Suono continuo</string>
@ -286,7 +284,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks pronuncerà il nome dell\'attività durante i promemoria</string>
<string name="delete_task">Elimina attività</string>
<string name="voice_command_added_task">Attività aggiunta</string>
<string name="external_storage_unavailable">Non posso accedere alla memoria esterna</string>
<plurals name="Ntasks">
<item quantity="one">1 attività</item>
<item quantity="other">%d attività</item>

@ -160,8 +160,6 @@
<string name="premium_remove_file_confirm">בטוח? לא ניתן לבטל את הפעולה</string>
<string name="audio_recording_title">מקליטה שֵׁמַע</string>
<string name="audio_stop_recording">הפסק הקלטה</string>
<string name="file_type_unhandled">\"מצטערת! לא מצאתי ישום שיכול לטפל בקבצים מסוג זה\"</string>
<string name="file_err_copy">שגיאה בהעתקת הקובץ המצורף</string>
<string name="ring_once">צלצל פעם אחת</string>
<string name="ring_five_times">צלצל חמש פעמים</string>
<string name="ring_nonstop">צלצל ללא הפסקה</string>
@ -281,7 +279,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">אסטריד תאמר את שם המשימה כחלק מהתזכורת</string>
<string name="delete_task">מחק משימה</string>
<string name="voice_command_added_task">משימות שנוצרו</string>
<string name="external_storage_unavailable">לא ניתן לגשת לאחסון חיצוני</string>
<plurals name="Ntasks">
<item quantity="one">משימה אחת</item>
<item quantity="other">%d משימות</item>

@ -160,8 +160,6 @@
<string name="premium_remove_file_confirm">よろしいですか? 取り消しできません</string>
<string name="audio_recording_title">音声を録音中</string>
<string name="audio_stop_recording">録音を停止</string>
<string name="file_type_unhandled">申し訳ありません! このファイル形式を扱うアプリケーションがありません.</string>
<string name="file_err_copy">ファイル添付のコピー中にエラー</string>
<string name="ring_once">1回通知音を鳴らす</string>
<string name="ring_five_times">5回通知音を鳴らす</string>
<string name="ring_nonstop">通知音を鳴らし続ける</string>
@ -281,7 +279,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks はタスクリマインダーでタスク名を話します</string>
<string name="delete_task">タスクを削除</string>
<string name="voice_command_added_task">追加されたタスク</string>
<string name="external_storage_unavailable">外部メモリーにアクセスできません</string>
<plurals name="Ntasks">
<item quantity="one">タスク 1 件</item>
<item quantity="other">タスク %d 件</item>

@ -162,8 +162,6 @@
<string name="premium_remove_file_confirm">정말입니까? 되돌릴 수 없습니다</string>
<string name="audio_recording_title">오디오 녹음 중</string>
<string name="audio_stop_recording">녹음 중단</string>
<string name="file_type_unhandled">죄송하지만 이 파일 형식을 다룰 수 있는 프로그램이 없습니다.</string>
<string name="file_err_copy">첨부용 파일 복사 에러</string>
<string name="ring_once">한번 울림</string>
<string name="ring_five_times">다섯번 울림</string>
<string name="ring_nonstop">계속 울림</string>
@ -283,7 +281,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">할일 알림 시 할일 제목을 소리내어 읽어줍니다</string>
<string name="delete_task">할일 지우기</string>
<string name="voice_command_added_task">추가된 할일</string>
<string name="external_storage_unavailable">외부 저장소에 접근할 수 없음</string>
<plurals name="Ntasks">
<item quantity="one">1 할일</item>
<item quantity="other">%d 할일</item>

@ -164,8 +164,6 @@
<string name="premium_remove_file_confirm">Ar tikrai? Nebegalės būti atstatyta</string>
<string name="audio_recording_title">Įrašyti garsą</string>
<string name="audio_stop_recording">Stabdyti įrašą</string>
<string name="file_type_unhandled">Atsiprašome! Nerasta programa, galinti atidaryti šio tipo failus.</string>
<string name="file_err_copy">Klaida bandant pridėti failą</string>
<string name="ring_once">Suskambėti vieną kartą</string>
<string name="ring_five_times">Suskambėti penkis kartus</string>
<string name="ring_nonstop">Skambėti nepaliaujant</string>
@ -285,7 +283,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks garsu praneš užduoties pavadinimą per priminimus</string>
<string name="delete_task">Ištrinti užduotį</string>
<string name="voice_command_added_task">Pridėtos užduotys</string>
<string name="external_storage_unavailable">Nėra prieigos prie išorinės laikmenos</string>
<plurals name="Ntasks">
<item quantity="one">1 užduotis</item>
<item quantity="other">%d užduotys(-čių)</item>

@ -164,8 +164,6 @@
<string name="premium_remove_file_confirm">Weet je het zeker? Dit kan niet ongedaan gemaakt worden</string>
<string name="audio_recording_title">Bezig met opname</string>
<string name="audio_stop_recording">Opname stoppen</string>
<string name="file_type_unhandled">Sorry! Er is geen applicatie gevonden die dit bestandtype ondersteunt.</string>
<string name="file_err_copy">Fout bij kopiëren toe te voegen bestand</string>
<string name="ring_once">Ring eenmalig</string>
<string name="ring_five_times">Ring vijf keer</string>
<string name="ring_nonstop">Ring continue</string>
@ -284,7 +282,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Bij herinneringen zullen de taaknamen uitgesproken worden</string>
<string name="delete_task">Verwijder taak</string>
<string name="voice_command_added_task">Toegevoegde taak</string>
<string name="external_storage_unavailable">Geen toegang tot externe opslag</string>
<plurals name="Ntasks">
<item quantity="one">1 taak</item>
<item quantity="other">%d taken</item>

@ -159,8 +159,6 @@
<string name="premium_remove_file_confirm">Jesteś pewny? Tych zmian nie można odwrócić</string>
<string name="audio_recording_title">Nagrywanie dźwięku</string>
<string name="audio_stop_recording">Zakończ nagrywanie</string>
<string name="file_type_unhandled">Przepraszamy! Nie znaleziono aplikacji do obsługi tego typu pliku.</string>
<string name="file_err_copy">Błąd kopiowania pliku do załącznika</string>
<string name="ring_once">Dzwoń raz</string>
<string name="ring_five_times">Dzwoń pięć razy</string>
<string name="ring_nonstop">Dzwoń nonstop</string>
@ -278,7 +276,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks będzie mówił nazwę zadania podczas przypomnienia</string>
<string name="delete_task">Usuń zadanie</string>
<string name="voice_command_added_task">Dodane zadanie</string>
<string name="external_storage_unavailable">Brak dostępu do pamięci zewnętrznej</string>
<plurals name="Ntasks">
<item quantity="one">1 zadanie</item>
<item quantity="other">%d zadań</item>

@ -166,8 +166,6 @@
<string name="premium_remove_file_confirm">Você tem certeza? Não pode ser desfeito.</string>
<string name="audio_recording_title">Gravando Áudio</string>
<string name="audio_stop_recording">Parar Gravação</string>
<string name="file_type_unhandled">Desculpa! Nenhuma aplicação para manipular este tipo de arquivo foi encontrada.</string>
<string name="file_err_copy">Erro ao copiar o arquivo para o anexo</string>
<string name="ring_once">Tocar uma vez</string>
<string name="ring_five_times">Tocar cinco vezes</string>
<string name="ring_nonstop">Tocar continuamente</string>
@ -287,7 +285,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks irá falar o nome das tarefas durante os lembretes</string>
<string name="delete_task">Excluir tarefa</string>
<string name="voice_command_added_task">Tarefa adicionada</string>
<string name="external_storage_unavailable">Não é possível acessar armazenamento externo</string>
<plurals name="Ntasks">
<item quantity="one">1 tarefa</item>
<item quantity="other">%d tarefas</item>

@ -154,8 +154,6 @@
<string name="premium_remove_file_confirm">Tem a certeza? A ação não pode ser anulada!</string>
<string name="audio_recording_title">Gravação áudio</string>
<string name="audio_stop_recording">Parar gravação</string>
<string name="file_type_unhandled">Não foi encontrada qualquer aplicação para gerir ficheiros deste tipo.</string>
<string name="file_err_copy">Erro ao copiar o ficheiro como anexo</string>
<string name="ring_once">Tocar uma vez</string>
<string name="ring_five_times">Tocar 5 vezes</string>
<string name="ring_nonstop">Tocar incessantemente</string>
@ -273,7 +271,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">O Tasks irá reproduzir o nome da tarefa durante os lembretes</string>
<string name="delete_task">Eliminar tarefa</string>
<string name="voice_command_added_task">Tarefa adicionada</string>
<string name="external_storage_unavailable">Não foi possível aceder ao disco externo</string>
<plurals name="Ntasks">
<item quantity="one">1 tarefa</item>
<item quantity="other">%d tarefas</item>

@ -158,8 +158,6 @@
<string name="premium_remove_file_confirm">Вы уверены? Действие нельзя отменить</string>
<string name="audio_recording_title">Запись голоса</string>
<string name="audio_stop_recording">Остановить запись</string>
<string name="file_type_unhandled">Извините! Не найдена программа для просмотра файлов этого типа.</string>
<string name="file_err_copy">Ошибка копирования прикрепляемого файла.</string>
<string name="ring_once">1 раз</string>
<string name="ring_five_times">5 раз</string>
<string name="ring_nonstop">Пока не выкл.</string>
@ -279,7 +277,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks должен произносить название задач во время напоминаний</string>
<string name="delete_task">Удалить задачу</string>
<string name="voice_command_added_task">Добавленная задача</string>
<string name="external_storage_unavailable">Нет доступа к внешнему хранилищу</string>
<plurals name="Ntasks">
<item quantity="one">1 задача</item>
<item quantity="other">%d задач(а/и)</item>

@ -153,8 +153,6 @@
<string name="premium_remove_file_confirm">Naozaj? Nedá sa vrátiť</string>
<string name="audio_recording_title">Nahrávanie zvuku</string>
<string name="audio_stop_recording">Zastaviť nahrávanie</string>
<string name="file_type_unhandled">Pre tento typ súboru nebola nájdená žiadna aplikácia</string>
<string name="file_err_copy">Chyba pri kopírovaní súboru do prílohy</string>
<string name="ring_once">Zvoniť raz</string>
<string name="ring_five_times">Zvoniť päť krát</string>
<string name="ring_nonstop">Zvoniť neustále</string>
@ -273,7 +271,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Úlohy vyslovia názov úlohy počas pripomineky</string>
<string name="delete_task">Zmazať úlohu</string>
<string name="voice_command_added_task">Pridaná úloha</string>
<string name="external_storage_unavailable">Nemožno získať prístup k externej pamäti</string>
<plurals name="Ntasks">
<item quantity="one">1 úloha</item>
<item quantity="other">%d úlohy</item>

@ -131,8 +131,6 @@
<string name="premium_remove_file_confirm">Ste prepričani? Tega ni mogoče razveljaviti</string>
<string name="audio_recording_title">Snemam zvok</string>
<string name="audio_stop_recording">Prekini snemanje</string>
<string name="file_type_unhandled">Žal nobena aplikacija ne ustreza takim datotekam.</string>
<string name="file_err_copy">Napaka pri kopiranju datoteke za priponko</string>
<string name="random_reminder_hour">na uro</string>
<string name="random_reminder_day">dan</string>
<string name="random_reminder_week">na teden</string>

@ -153,8 +153,6 @@
<string name="premium_remove_file_confirm">Är du säker? Detta kan inte ångras</string>
<string name="audio_recording_title">Spelar in ljud</string>
<string name="audio_stop_recording">Avsluta inspelning</string>
<string name="file_type_unhandled">Tyvärr hittades ingen applikation för att hantera den här filtypen</string>
<string name="file_err_copy">Ett fel uppstod vid kopiering av filen till bilaga</string>
<string name="ring_once">Ring en gång</string>
<string name="ring_five_times">Ring fem gånger</string>
<string name="ring_nonstop">Ring konstant</string>
@ -198,7 +196,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks läser upp uppgifterna vid påminnelse</string>
<string name="delete_task">Radera uppgift</string>
<string name="voice_command_added_task">Uppgift skapad</string>
<string name="external_storage_unavailable">Kan inte komma åt externt lagringsutrymme</string>
<plurals name="Ntasks">
<item quantity="one">1 uppgift</item>
<item quantity="other">%d uppgifter</item>

@ -170,8 +170,6 @@
<string name="premium_remove_file_confirm">Emin misiniz? Geri döndürülemez</string>
<string name="audio_recording_title">Ses Kaydediliyor</string>
<string name="audio_stop_recording">Kaydı Durdur</string>
<string name="file_type_unhandled">Üzgünüm! Bu dosya türünü destekleyen bir uygulama bulunamadı.</string>
<string name="file_err_copy">Dosyanın ek olarak kopyalanmasında hata</string>
<string name="ring_once">Bir kez çal</string>
<string name="ring_five_times">5 kez çal</string>
<string name="ring_nonstop">Durmadan çal</string>
@ -291,7 +289,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks, görev adlarını görev hatırlatmaları sırasında söyleyecek</string>
<string name="delete_task">Görevi sil</string>
<string name="voice_command_added_task">Görev eklendi</string>
<string name="external_storage_unavailable">Dış depolamaya ulaşılamadı.</string>
<plurals name="Ntasks">
<item quantity="one">1 görev</item>
<item quantity="other">%d görev</item>

@ -155,8 +155,6 @@
<string name="premium_remove_file_confirm">Ви впевнені? Це не може бути скасовано.</string>
<string name="audio_recording_title">Запис аудіо</string>
<string name="audio_stop_recording">Зупинити запис</string>
<string name="file_type_unhandled">Вибачте! Не знайдено програму для перегляду файлів цього типу.</string>
<string name="file_err_copy">Помилка копіювання файлу для вкладення</string>
<string name="ring_once">Дзвеніти 1 раз</string>
<string name="ring_five_times">Дзвеніти 5 раз</string>
<string name="ring_nonstop">Дзвеніти безперервно</string>
@ -201,7 +199,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks повинен вимовляти назву завдань під час нагадувань</string>
<string name="delete_task">Видалити завдання</string>
<string name="voice_command_added_task">Додане завдання</string>
<string name="external_storage_unavailable">Немає доступу до зовнішнього сховища</string>
<plurals name="Ntasks">
<item quantity="one">1 завдання</item>
<item quantity="other">%d завдань</item>

@ -159,8 +159,6 @@
<string name="premium_remove_file_confirm">您确定吗?无法恢复的喔</string>
<string name="audio_recording_title">正在录制音频</string>
<string name="audio_stop_recording">停止录制</string>
<string name="file_type_unhandled">对不起!找不到应用程序处理这种文件类型。</string>
<string name="file_err_copy">复制文件添加附件时出错</string>
<string name="ring_once">响铃一次</string>
<string name="ring_five_times">响铃五次</string>
<string name="ring_nonstop">响个不停</string>
@ -280,7 +278,6 @@
<string name="EPr_voiceRemindersEnabled_desc_enabled">Tasks会在任务提醒时读出任务名</string>
<string name="delete_task">删除任务</string>
<string name="voice_command_added_task">已添加的任务</string>
<string name="external_storage_unavailable">无法访问外部存储</string>
<plurals name="Ntasks">
<item quantity="one">1 个任务</item>
<item quantity="other">%d 个任务</item>

@ -124,8 +124,6 @@
<string name="premium_remove_file_confirm">您確定嗎?無法恢復的喔</string>
<string name="audio_recording_title">正在錄製音頻</string>
<string name="audio_stop_recording">停止錄製</string>
<string name="file_type_unhandled">對不起!找不到應用程序處理這種文件類型。</string>
<string name="file_err_copy">複製文件添加附件時出錯</string>
<string name="ring_once">響鈴一次</string>
<string name="ring_five_times">響鈴五次</string>
<string name="ring_nonstop">不斷響鈴</string>

@ -419,10 +419,6 @@ File %1$s contained %2$s.\n\n
<string name="audio_recording_title">Recording Audio</string>
<string name="audio_stop_recording">Stop Recording</string>
<string name="file_type_unhandled">Sorry! No application was found to handle this file type.</string>
<string name="file_err_copy">Error copying file for attachment</string>
<!-- slide 45a: Task Edit: Reminder mode: ring once -->
<string name="ring_once">Ring once</string>
@ -612,7 +608,6 @@ File %1$s contained %2$s.\n\n
<string name="delete_task">Delete task</string>
<string name="voice_command_added_task">Added task</string>
<string name="external_storage_unavailable">Cannot access external storage</string>
<!-- ==================================================== Generic Units == -->

@ -3,4 +3,7 @@
<root-path
name="root"
path="." />
<cache-path
name="cache"
path="." />
</paths>
Loading…
Cancel
Save