diff --git a/android/app/src/main/java/app/organicmaps/MwmActivity.java b/android/app/src/main/java/app/organicmaps/MwmActivity.java index abe4a7471..c40fb99a3 100644 --- a/android/app/src/main/java/app/organicmaps/MwmActivity.java +++ b/android/app/src/main/java/app/organicmaps/MwmActivity.java @@ -44,6 +44,7 @@ import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.ViewModelProvider; import app.organicmaps.api.Const; +import app.organicmaps.backup.PeriodicBackupRunner; import app.organicmaps.base.BaseMwmFragmentActivity; import app.organicmaps.base.OnBackPressListener; import app.organicmaps.bookmarks.BookmarkCategoriesActivity; @@ -139,6 +140,7 @@ import static app.organicmaps.leftbutton.LeftButtonsHolder.BUTTON_HELP_CODE; import static app.organicmaps.leftbutton.LeftButtonsHolder.BUTTON_RECORD_TRACK_CODE; import static app.organicmaps.leftbutton.LeftButtonsHolder.BUTTON_SETTINGS_CODE; import static app.organicmaps.util.PowerManagment.POWER_MANAGEMENT_TAG; +import static app.organicmaps.util.concurrency.UiThread.runLater; public class MwmActivity extends BaseMwmFragmentActivity implements PlacePageActivationListener, @@ -253,6 +255,8 @@ public class MwmActivity extends BaseMwmFragmentActivity @NonNull private DisplayManager mDisplayManager; + private PeriodicBackupRunner backupRunner; + ManageRouteBottomSheet mManageRouteBottomSheet; private boolean mRemoveDisplayListener = true; @@ -607,6 +611,8 @@ public class MwmActivity extends BaseMwmFragmentActivity */ if (Map.isEngineCreated()) onRenderingInitializationFinished(); + + backupRunner = new PeriodicBackupRunner(this); } private void onSettingsResult(ActivityResult activityResult) @@ -1352,6 +1358,11 @@ public class MwmActivity extends BaseMwmFragmentActivity final String backUrl = Framework.nativeGetParsedBackUrl(); if (!TextUtils.isEmpty(backUrl)) Utils.openUri(this, Uri.parse(backUrl), null); + + if (backupRunner != null && !backupRunner.isAlreadyChecked() && backupRunner.isTimeToBackup()) + { + backupRunner.doBackup(); + } } @CallSuper diff --git a/android/app/src/main/java/app/organicmaps/backup/BackupUtils.java b/android/app/src/main/java/app/organicmaps/backup/BackupUtils.java new file mode 100644 index 000000000..74d88e65f --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/backup/BackupUtils.java @@ -0,0 +1,114 @@ +package app.organicmaps.backup; + +import static app.organicmaps.settings.BackupSettingsFragment.MAX_BACKUPS_DEFAULT_COUNT; +import static app.organicmaps.settings.BackupSettingsFragment.MAX_BACKUPS_KEY; +import static app.organicmaps.util.StorageUtils.isFolderWritable; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.provider.DocumentsContract; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.AbsoluteSizeSpan; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import app.organicmaps.R; +import app.organicmaps.util.UiUtils; +import app.organicmaps.util.log.Logger; + +public class BackupUtils +{ + private static final String BACKUP_PREFIX = "backup_"; + private static final String BACKUP_EXTENSION = ".kmz"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").withLocale(Locale.US); + private static final String TAG = BackupUtils.class.getSimpleName(); + + public static CharSequence formatReadableFolderPath(Context context, @NonNull Uri uri) + { + String docId = DocumentsContract.getTreeDocumentId(uri); + String volumeId; + String subPath = ""; + + int colonIndex = docId.indexOf(':'); + if (colonIndex >= 0) + { + volumeId = docId.substring(0, colonIndex); + subPath = docId.substring(colonIndex + 1); + } + else + { + volumeId = docId; + } + + String volumeName; + if ("primary".equalsIgnoreCase(volumeId)) + volumeName = context.getString(R.string.maps_storage_shared); + else + volumeName = context.getString(R.string.maps_storage_removable); + + SpannableStringBuilder sb = new SpannableStringBuilder(); + sb.append(volumeName + ": \n", new AbsoluteSizeSpan(UiUtils.dimen(context, R.dimen.text_size_body_3)), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + sb.append("/" + subPath, new AbsoluteSizeSpan(UiUtils.dimen(context, R.dimen.text_size_body_4)), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return sb; + } + + public static int getMaxBackups(SharedPreferences prefs) + { + String rawValue = prefs.getString(MAX_BACKUPS_KEY, String.valueOf(MAX_BACKUPS_DEFAULT_COUNT)); + try + { + return Integer.parseInt(rawValue); + } catch (NumberFormatException e) + { + Logger.e(TAG, "Failed to parse max backups count, raw value: " + rawValue + " set to default: " + MAX_BACKUPS_DEFAULT_COUNT, e); + prefs.edit() + .putString(MAX_BACKUPS_KEY, String.valueOf(MAX_BACKUPS_DEFAULT_COUNT)) + .apply(); + return MAX_BACKUPS_DEFAULT_COUNT; + } + } + + public static DocumentFile createUniqueBackupFolder(@NonNull DocumentFile parentDir, LocalDateTime backupTime) + { + String folderName = BACKUP_PREFIX + backupTime.format(DATE_FORMATTER); + return parentDir.createDirectory(folderName); + } + + public static String getBackupName(LocalDateTime backupTime) + { + String formattedBackupTime = backupTime.format(DATE_FORMATTER); + return BACKUP_PREFIX + formattedBackupTime + BACKUP_EXTENSION; + } + + public static DocumentFile[] getBackupFolders(DocumentFile parentDir) + { + List backupFolders = new ArrayList<>(); + for (DocumentFile file : parentDir.listFiles()) + { + if (file.isDirectory() && file.getName() != null && file.getName().startsWith(BACKUP_PREFIX)) + backupFolders.add(file); + } + return backupFolders.toArray(new DocumentFile[0]); + } + + public static boolean isBackupFolderAvailable(Context context, String storedFolderPath) + { + return !TextUtils.isEmpty(storedFolderPath) && isFolderWritable(context, storedFolderPath); + } +} diff --git a/android/app/src/main/java/app/organicmaps/backup/LocalBackupManager.java b/android/app/src/main/java/app/organicmaps/backup/LocalBackupManager.java new file mode 100644 index 000000000..628b951a7 --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/backup/LocalBackupManager.java @@ -0,0 +1,189 @@ +package app.organicmaps.backup; + +import static app.organicmaps.backup.BackupUtils.getBackupName; +import static app.organicmaps.backup.BackupUtils.getBackupFolders; +import static app.organicmaps.util.StorageUtils.copyFileToDocumentFile; +import static app.organicmaps.util.StorageUtils.deleteDirectoryRecursive; + +import android.app.Activity; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; + +import java.io.File; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import app.organicmaps.bookmarks.data.BookmarkCategory; +import app.organicmaps.bookmarks.data.BookmarkManager; +import app.organicmaps.bookmarks.data.BookmarkSharingResult; +import app.organicmaps.bookmarks.data.KmlFileType; +import app.organicmaps.util.concurrency.ThreadPool; +import app.organicmaps.util.concurrency.UiThread; +import app.organicmaps.util.log.Logger; + +public class LocalBackupManager implements BookmarkManager.BookmarksSharingListener +{ + public static final String TAG = LocalBackupManager.class.getSimpleName(); + + private final Activity activity; + private final String backupFolderPath; + private final int maxBackups; + private Listener listener; + + public LocalBackupManager(@NonNull Activity activity, @NonNull String backupFolderPath, int maxBackups) + { + this.activity = activity; + this.backupFolderPath = backupFolderPath; + this.maxBackups = maxBackups; + } + + public void doBackup() + { + BookmarkManager.INSTANCE.addSharingListener(this); + + prepareBookmarkCategoriesForSharing(); + + if (listener != null) + listener.onBackupStarted(); + } + + public void setListener(@NonNull Listener listener) + { + this.listener = listener; + } + + @Override + public void onPreparedFileForSharing(@NonNull BookmarkSharingResult result) + { + BookmarkManager.INSTANCE.removeSharingListener(this); + + ThreadPool.getWorker().execute(() -> { + ErrorCode errorCode = null; + switch (result.getCode()) + { + case BookmarkSharingResult.SUCCESS -> + { + if (!saveBackup(result)) + { + Logger.e(TAG, "Failed to save backup. See system log above"); + errorCode = ErrorCode.FILE_ERROR; + } + else + { + Logger.i(TAG, "Backup was created and saved successfully"); + } + } + case BookmarkSharingResult.EMPTY_CATEGORY -> + { + errorCode = ErrorCode.EMPTY_CATEGORY; + Logger.e(TAG, "Failed to create backup. Category is empty"); + } + case BookmarkSharingResult.ARCHIVE_ERROR -> + { + errorCode = ErrorCode.ARCHIVE_ERROR; + Logger.e(TAG, "Failed to create archive of bookmarks"); + } + case BookmarkSharingResult.FILE_ERROR -> + { + errorCode = ErrorCode.FILE_ERROR; + Logger.e(TAG, "Failed create file for archive"); + } + default -> + { + errorCode = ErrorCode.UNSUPPORTED; + Logger.e(TAG, "Failed to create backup. Unknown error"); + } + } + + ErrorCode finalErrorCode = errorCode; + UiThread.run(() -> { + if (listener != null) + { + if (finalErrorCode == null) + listener.onBackupFinished(); + else + listener.onBackupFailed(finalErrorCode); + } + }); + }); + } + + private boolean saveBackup(@NonNull BookmarkSharingResult result) + { + boolean isSuccess = false; + Uri folderUri = Uri.parse(backupFolderPath); + try + { + DocumentFile parentFolder = DocumentFile.fromTreeUri(activity, folderUri); + if (parentFolder != null && parentFolder.canWrite()) + { + LocalDateTime now = LocalDateTime.now(); + DocumentFile backupFolder = BackupUtils.createUniqueBackupFolder(parentFolder, now); + if (backupFolder != null) + { + String backupName = getBackupName(now); + DocumentFile backupFile = backupFolder.createFile(result.getMimeType(), backupName); + if (backupFile != null && copyFileToDocumentFile(activity, new File(result.getSharingPath()), backupFile)) + { + Logger.i(TAG, "Backup saved to " + backupFile.getUri()); + isSuccess = true; + } + } + else + { + Logger.e(TAG, "Failed to create backup folder"); + } + } + cleanOldBackups(parentFolder); + + } catch (Exception e) + { + Logger.e(TAG, "Failed to save backup", e); + } + return isSuccess; + } + + public void cleanOldBackups(DocumentFile parentDir) + { + DocumentFile[] backupFolders = getBackupFolders(parentDir); + if (backupFolders.length > maxBackups) + { + Arrays.sort(backupFolders, Comparator.comparing(DocumentFile::getName)); + for (int i = 0; i < backupFolders.length - maxBackups; i++) + { + Logger.i(TAG, "Delete old backup " + backupFolders[i].getUri()); + deleteDirectoryRecursive(backupFolders[i]); + } + } + } + + private void prepareBookmarkCategoriesForSharing() + { + List categories = BookmarkManager.INSTANCE.getCategories(); + long[] categoryIds = new long[categories.size()]; + for (int i = 0; i < categories.size(); i++) + categoryIds[i] = categories.get(i).getId(); + BookmarkManager.INSTANCE.prepareCategoriesForSharing(categoryIds, KmlFileType.Text); + } + + public interface Listener + { + void onBackupStarted(); + + void onBackupFinished(); + + void onBackupFailed(ErrorCode errorCode); + } + + public enum ErrorCode + { + EMPTY_CATEGORY, + ARCHIVE_ERROR, + FILE_ERROR, + UNSUPPORTED, + } +} diff --git a/android/app/src/main/java/app/organicmaps/backup/PeriodicBackupRunner.java b/android/app/src/main/java/app/organicmaps/backup/PeriodicBackupRunner.java new file mode 100644 index 000000000..a2d95198f --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/backup/PeriodicBackupRunner.java @@ -0,0 +1,104 @@ +package app.organicmaps.backup; + +import static app.organicmaps.backup.BackupUtils.getMaxBackups; +import static app.organicmaps.backup.BackupUtils.isBackupFolderAvailable; +import static app.organicmaps.settings.BackupSettingsFragment.BACKUP_FOLDER_PATH_KEY; +import static app.organicmaps.settings.BackupSettingsFragment.BACKUP_INTERVAL_KEY; +import static app.organicmaps.settings.BackupSettingsFragment.LAST_BACKUP_TIME_KEY; +import static app.organicmaps.util.StorageUtils.isFolderWritable; + +import android.app.Activity; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +import app.organicmaps.util.log.Logger; + +public class PeriodicBackupRunner +{ + private final Activity activity; + private static final String TAG = PeriodicBackupRunner.class.getSimpleName(); + private final SharedPreferences prefs; + private boolean alreadyChecked = false; + + public PeriodicBackupRunner(Activity activity) + { + this.activity = activity; + this.prefs = PreferenceManager.getDefaultSharedPreferences(activity); + } + + public boolean isAlreadyChecked() + { + return alreadyChecked; + } + + public boolean isTimeToBackup() + { + long intervalMs = getBackupIntervalMs(); + + if (intervalMs <= 0) + return false; + + long lastBackupTime = prefs.getLong(LAST_BACKUP_TIME_KEY, 0); + long now = System.currentTimeMillis(); + + alreadyChecked = true; + + return (now - lastBackupTime) >= intervalMs; + } + + public void doBackup() + { + String storedFolderPath = prefs.getString(BACKUP_FOLDER_PATH_KEY, null); + + if (isBackupFolderAvailable(activity, storedFolderPath)) + { + Logger.i(TAG, "Performing periodic backup"); + performBackup(storedFolderPath, getMaxBackups(prefs)); + } + else + { + Logger.w(TAG, "Backup folder is not writable, passed path: " + storedFolderPath); + } + } + + private long getBackupIntervalMs() + { + String defaultValue = "0"; + try + { + return Long.parseLong(prefs.getString(BACKUP_INTERVAL_KEY, defaultValue)); + } catch (NumberFormatException e) + { + return 0; + } + } + + private void performBackup(String backupFolderPath, int maxBackups) + { + LocalBackupManager backupManager = new LocalBackupManager(activity, backupFolderPath, maxBackups); + backupManager.setListener(new LocalBackupManager.Listener() + { + @Override + public void onBackupStarted() + { + Logger.i(TAG, "Periodic backup started"); + } + + @Override + public void onBackupFinished() + { + prefs.edit().putLong(LAST_BACKUP_TIME_KEY, System.currentTimeMillis()).apply(); + Logger.i(TAG, "Periodic backup finished"); + } + + @Override + public void onBackupFailed(LocalBackupManager.ErrorCode errorCode) + { + Logger.e(TAG, "Periodic backup was failed with code: " + errorCode); + } + }); + + backupManager.doBackup(); + } +} diff --git a/android/app/src/main/java/app/organicmaps/settings/BackupSettingsFragment.java b/android/app/src/main/java/app/organicmaps/settings/BackupSettingsFragment.java new file mode 100644 index 000000000..7e578f443 --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/settings/BackupSettingsFragment.java @@ -0,0 +1,384 @@ +package app.organicmaps.settings; + +import static app.organicmaps.backup.BackupUtils.formatReadableFolderPath; +import static app.organicmaps.backup.BackupUtils.getMaxBackups; +import static app.organicmaps.backup.BackupUtils.isBackupFolderAvailable; +import static app.organicmaps.util.StorageUtils.isFolderWritable; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.text.DateFormat; + +import app.organicmaps.R; +import app.organicmaps.backup.LocalBackupManager; +import app.organicmaps.util.log.Logger; + + +public class BackupSettingsFragment + extends BaseXmlSettingsFragment +{ + private ActivityResultLauncher folderPickerLauncher; + + private static final String TAG = LocalBackupManager.class.getSimpleName(); + public static final String BACKUP_FOLDER_PATH_KEY = "backup_location"; + public static final String LAST_BACKUP_TIME_KEY = "last_backup_time"; + private static final String BACKUP_NOW_KEY = "backup_now"; + public static final String BACKUP_INTERVAL_KEY = "backup_history_interval"; + public static final String MAX_BACKUPS_KEY = "backup_history_count"; + public static final int MAX_BACKUPS_DEFAULT_COUNT = 10; + public static final String DEFAULT_BACKUP_INTERVAL = "86400000"; // 24 hours in ms + + private LocalBackupManager mBackupManager; + private SharedPreferences prefs; + + @Override + protected int getXmlResources() + { + return R.xml.prefs_backup; + } + + @NonNull + @SuppressWarnings("NotNullFieldNotInitialized") + Preference backupLocationOption; + @NonNull + @SuppressWarnings("NotNullFieldNotInitialized") + ListPreference backupIntervalOption; + @NonNull + @SuppressWarnings("NotNullFieldNotInitialized") + Preference maxBackupsOption; + @NonNull + @SuppressWarnings("NotNullFieldNotInitialized") + Preference backupNowOption; + @NonNull + @SuppressWarnings("NotNullFieldNotInitialized") + Preference advancedCategory; + + @Override + public void onCreate(Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + + folderPickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + boolean isSuccess = false; + + String lastFolderPath = prefs.getString(BACKUP_FOLDER_PATH_KEY, null); + + if (result.getResultCode() == Activity.RESULT_OK) + { + Intent data = result.getData(); + Logger.i(TAG, "Folder selection result: " + data); + if (data == null) + return; + + Uri uri = data.getData(); + if (uri != null) + { + takePersistableUriPermission(uri); + Logger.i(TAG, "Backup location changed to " + uri); + prefs.edit().putString(BACKUP_FOLDER_PATH_KEY, uri.toString()).apply(); + setFormattedBackupPath(uri); + + runBackup(); + + isSuccess = true; + } + else + { + Logger.w(TAG, "Folder selection result is null"); + } + } + else if (result.getResultCode() == Activity.RESULT_CANCELED) + { + Logger.w(TAG, "User canceled folder selection"); + if (TextUtils.isEmpty(lastFolderPath)) + { + prefs.edit().putString(BACKUP_FOLDER_PATH_KEY, null).apply(); + Logger.i(TAG, "Backup settings reset"); + initBackupLocationOption(); + } + else if (isFolderWritable(requireActivity(), lastFolderPath)) + { + Logger.i(TAG, "Backup location not changed, using previous value " + lastFolderPath); + isSuccess = true; + } + else + { + Logger.e(TAG, "Backup location not changed, but last folder is not writable: " + lastFolderPath); + } + } + + resetLastBackupTime(); + updateStatusSummaryOption(); + + Logger.i(TAG, "Folder selection result: " + isSuccess); + applyAdvancedSettings(isSuccess); + } + ); + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) + { + super.onCreatePreferences(savedInstanceState, rootKey); + + prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); + backupLocationOption = findPreference(BACKUP_FOLDER_PATH_KEY); + backupIntervalOption = findPreference(BACKUP_INTERVAL_KEY); + maxBackupsOption = findPreference(MAX_BACKUPS_KEY); + backupNowOption = findPreference(BACKUP_NOW_KEY); + + initBackupLocationOption(); + initBackupIntervalOption(); + initMaxBackupsOption(); + initBackupNowOption(); + } + + + private void initBackupLocationOption() + { + String storedFolderPath = prefs.getString(BACKUP_FOLDER_PATH_KEY, null); + boolean isEnabled = false; + if (!TextUtils.isEmpty(storedFolderPath)) + { + if (isFolderWritable(requireContext(), storedFolderPath)) + { + setFormattedBackupPath(Uri.parse(storedFolderPath)); + isEnabled = true; + } + else + { + Logger.e(TAG, "Backup location is not available, path: " + storedFolderPath); + showBackupErrorAlertDialog(requireContext().getString(R.string.dialog_report_error_missing_folder)); + backupLocationOption.setSummary(requireContext().getString(R.string.pref_backup_now_summary_folder_unavailable)); + } + } + else + { + backupLocationOption.setSummary(requireContext().getString(R.string.pref_backup_location_summary_initial)); + } + + applyAdvancedSettings(isEnabled); + + backupLocationOption.setOnPreferenceClickListener(preference -> { + launchFolderPicker(); + + return true; + }); + } + + private void setFormattedBackupPath(@NonNull Uri uri) + { + backupLocationOption.setSummary(formatReadableFolderPath(requireContext(), uri)); + } + + private void initBackupIntervalOption() + { + String backupInterval = prefs.getString(BACKUP_INTERVAL_KEY, DEFAULT_BACKUP_INTERVAL); + + CharSequence entry = getEntryForValue(backupIntervalOption, backupInterval); + if (entry != null) + backupIntervalOption.setSummary(entry); + + backupIntervalOption.setOnPreferenceChangeListener((preference, newValue) -> { + CharSequence newEntry = getEntryForValue(backupIntervalOption, newValue.toString()); + Logger.i(TAG, "auto backup interval changed to " + newEntry); + if (newEntry != null) + backupIntervalOption.setSummary(newEntry); + + return true; + }); + } + + private void initMaxBackupsOption() + { + maxBackupsOption.setSummary(String.valueOf(getMaxBackups(prefs))); + + maxBackupsOption.setOnPreferenceChangeListener((preference, newValue) -> { + maxBackupsOption.setSummary(newValue.toString()); + + return true; + }); + } + + private void initBackupNowOption() + { + updateStatusSummaryOption(); + backupNowOption.setOnPreferenceClickListener(preference -> { + runBackup(); + + return true; + }); + } + + private void updateStatusSummaryOption() + { + long lastBackupTime = prefs.getLong(LAST_BACKUP_TIME_KEY, 0L); + + String summary; + if (lastBackupTime > 0) + { + String time = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(lastBackupTime); + summary = requireContext().getString(R.string.pref_backup_status_summary_success) + ": " + time; + } + else + { + summary = requireContext().getString(R.string.pref_backup_now_summary); + } + + backupNowOption.setSummary(summary); + } + + private void resetLastBackupTime() + { + prefs.edit().remove(LAST_BACKUP_TIME_KEY).apply(); + } + + private void applyAdvancedSettings(boolean isBackupEnabled) + { + backupIntervalOption.setVisible(isBackupEnabled); + maxBackupsOption.setVisible(isBackupEnabled); + backupNowOption.setVisible(isBackupEnabled); + } + + + private void runBackup() + { + String currentFolderPath = prefs.getString(BACKUP_FOLDER_PATH_KEY, null); + if (!TextUtils.isEmpty(currentFolderPath)) + { + if (isFolderWritable(requireContext(), currentFolderPath)) + { + mBackupManager = new LocalBackupManager(requireActivity(), currentFolderPath, getMaxBackups(prefs)); + mBackupManager.setListener(new LocalBackupManager.Listener() + { + @Override + public void onBackupStarted() + { + Logger.i(TAG, "Manual backup started"); + + backupNowOption.setEnabled(false); + backupNowOption.setSummary(R.string.pref_backup_now_summary_progress); + } + + @Override + public void onBackupFinished() + { + Logger.i(TAG, "Manual backup successful"); + + backupNowOption.setEnabled(true); + backupNowOption.setSummary(R.string.pref_backup_now_summary_ok); + + prefs.edit().putLong(LAST_BACKUP_TIME_KEY, System.currentTimeMillis()).apply(); + } + + @Override + public void onBackupFailed(LocalBackupManager.ErrorCode errorCode) + { + String errorMessage = switch (errorCode) + { + case EMPTY_CATEGORY -> requireContext().getString(R.string.pref_backup_now_summary_empty_lists); + default -> requireContext().getString(R.string.pref_backup_now_summary_failed); + }; + + Logger.e(TAG, "Manual backup was failed with code: " + errorCode); + + backupNowOption.setEnabled(true); + backupNowOption.setSummary(errorMessage); + + showBackupErrorAlertDialog(requireContext().getString(R.string.dialog_report_error_with_logs)); + } + }); + + mBackupManager.doBackup(); + } + else + { + backupNowOption.setSummary(R.string.pref_backup_now_summary_folder_unavailable); + showBackupErrorAlertDialog(requireContext().getString(R.string.dialog_report_error_missing_folder)); + Logger.e(TAG, "Manual backup error: folder " + currentFolderPath + " unavailable"); + } + } + else + { + backupNowOption.setSummary(R.string.pref_backup_now_summary_folder_unavailable); + Logger.e(TAG, "Manual backup error: no folder selected"); + } + } + + private void launchFolderPicker() + { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + intent.putExtra("android.content.extra.SHOW_ADVANCED", true); + + PackageManager packageManager = requireActivity().getPackageManager(); + if (intent.resolveActivity(packageManager) != null) + folderPickerLauncher.launch(intent); + else + showNoFileManagerError(); + } + + private void showNoFileManagerError() + { + new MaterialAlertDialogBuilder(requireActivity()) + .setMessage(R.string.error_no_file_manager_app) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .show(); + } + + private void showBackupErrorAlertDialog(String message) + { + requireActivity().runOnUiThread(() -> { + new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.pref_backup_now_summary_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .show(); + }); + } + + private void takePersistableUriPermission(Uri uri) + { + requireContext().getContentResolver().takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ); + } + + @Nullable + public static CharSequence getEntryForValue(@NonNull ListPreference listPref, @NonNull CharSequence value) + { + CharSequence[] entryValues = listPref.getEntryValues(); + CharSequence[] entries = listPref.getEntries(); + + if (entryValues == null || entries == null) + return null; + + for (int i = 0; i < entryValues.length; i++) + { + if (entryValues[i].equals(value)) + return entries[i]; + } + return null; + } +} diff --git a/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java b/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java index ecf643c59..25fe6123e 100644 --- a/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java +++ b/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java @@ -189,6 +189,10 @@ public class SettingsPrefsFragment extends BaseXmlSettingsFragment implements La LanguagesFragment langFragment = (LanguagesFragment)getSettingsActivity().stackFragment(LanguagesFragment.class, getString(R.string.change_map_locale), null); langFragment.setListener(this); } + else if (key.equals(getString(R.string.pref_backup))) + { + getSettingsActivity().stackFragment(BackupSettingsFragment.class, getString(R.string.pref_backup_title), null); + } } return super.onPreferenceTreeClick(preference); } diff --git a/android/app/src/main/java/app/organicmaps/util/StorageUtils.java b/android/app/src/main/java/app/organicmaps/util/StorageUtils.java index 943eed24c..7c40483e9 100644 --- a/android/app/src/main/java/app/organicmaps/util/StorageUtils.java +++ b/android/app/src/main/java/app/organicmaps/util/StorageUtils.java @@ -1,5 +1,6 @@ package app.organicmaps.util; +import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.content.pm.PackageManager; @@ -10,10 +11,13 @@ import android.provider.DocumentsContract; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.FileProvider; +import androidx.documentfile.provider.DocumentFile; + import app.organicmaps.BuildConfig; import app.organicmaps.util.log.Logger; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; @@ -323,4 +327,76 @@ public class StorageUtils } } } + + public static boolean copyFileToDocumentFile( + @NonNull Activity activity, + @NonNull File sourceFile, + @NonNull DocumentFile targetFile + ) + { + try ( + InputStream in = new FileInputStream(sourceFile); + OutputStream out = activity.getContentResolver().openOutputStream(targetFile.getUri()) + ) + { + if (out == null) + { + Logger.e(TAG, "Failed to open output stream for " + targetFile.getUri()); + return false; + } + + byte[] buffer = new byte[8192]; + int length; + + while ((length = in.read(buffer)) > 0) + out.write(buffer, 0, length); + + out.flush(); + return true; + } catch (IOException e) + { + Logger.e(TAG, "Failed to copy file from " + sourceFile.getAbsolutePath() + " to " + targetFile.getUri(), e); + return false; + } + } + + public static void deleteDirectoryRecursive(@NonNull DocumentFile dir) + { + try + { + for (DocumentFile file : dir.listFiles()) + { + if (file.isDirectory()) + deleteDirectoryRecursive(file); + else + file.delete(); + } + dir.delete(); + } catch (Exception e) + { + Logger.e(TAG, "Failed to delete directory: " + dir.getUri(), e); + } + } + + public static boolean isFolderWritable(Context context, String folderPath) + { + try + { + Uri folderUri = Uri.parse(folderPath); + DocumentFile folder = DocumentFile.fromTreeUri(context, folderUri); + if (folder != null && folder.canWrite()) + { + DocumentFile tempFile = folder.createFile("application/octet-stream", "temp_file"); + if (tempFile != null) + { + tempFile.delete(); + return true; + } + } + } catch (Exception e) + { + Logger.e(TAG, "Failed to check if folder is writable: " + folderPath, e); + } + return false; + } } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 0b03a5b6f..6b3fbf012 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -911,4 +911,29 @@ Пешеход Очистить Вид маршрута + + Резервное копирование меток и треков + Автоматически сохранять в папку на устройстве + Создать резервную копию + Запустить резервное копирование вручную + Идёт резервное копирование… + Копирование успешно завершено + Нет данных для копирования + Ошибка при копировании + Папка для копий недоступна + Последнее успешное копирование + Папка для резервных копий + Сначала выберите папку и дайте доступ + Хранить количество копий + Автозапуск + Каждый день + Каждую неделю + Выключено (только вручную) + Выбранная папка для резервного копирования недоступна или нет права записи в неё. Пожалуйста, выберите другую папку + Пожалуйста, отправьте нам отчет об ошибке:\n + - Включите \"Запись логов\" в настройках\n + - воспроизведите проблему\n + - на экране \"Справка\" нажмите кнопку \"Сообщить о проблеме\" и отправьте нам отчет по почте или в чат\n + - отключите логирование + diff --git a/android/app/src/main/res/values/donottranslate.xml b/android/app/src/main/res/values/donottranslate.xml index 56f2ec536..f0ea2b2f9 100644 --- a/android/app/src/main/res/values/donottranslate.xml +++ b/android/app/src/main/res/values/donottranslate.xml @@ -40,6 +40,7 @@ KeepScreenOn ShowOnLockScreen MapLanguage + Backup LeftButton %1$s: %2$s diff --git a/android/app/src/main/res/values/string-arrays.xml b/android/app/src/main/res/values/string-arrays.xml index ee0e6210f..c8678622b 100644 --- a/android/app/src/main/res/values/string-arrays.xml +++ b/android/app/src/main/res/values/string-arrays.xml @@ -23,7 +23,29 @@ 0 1 - + + + @string/backup_interval_every_day + @string/backup_interval_every_week + @string/backup_interval_manual_only + + + + 86400000 + 604800000 + 0 + + + + 3 + 10 + + + + 3 + 10 + + @string/off @string/on diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 257a47a91..5d4c8804e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -936,6 +936,32 @@ Codeberg Left button setup Disable + + + Bookmarks and tracks backup + Automatically backup to a folder on your device + Backup now + Create a backup immediately + Backup in progress… + Backup completed successfully + Nothing to back up + Backup failed + The backup folder is not available + Last successful backup + Backup location + Please select a folder first and grant permission + Number of backups to keep + Automatic backup + Daily + Weekly + Off (manual only) + The selected backup location is not available or writable. Select a different location, please. + Please send us an error report:\n + - \"Enable logging\" in the settings\n + - reproduce the problem\n + - in the \"Help/About\" screen press a \"Report a bug\" button and send it to us via email or chat\n + - disable logging + Clear Route type Vehicle diff --git a/android/app/src/main/res/xml/prefs_backup.xml b/android/app/src/main/res/xml/prefs_backup.xml new file mode 100644 index 000000000..ce8d1d034 --- /dev/null +++ b/android/app/src/main/res/xml/prefs_backup.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/prefs_main.xml b/android/app/src/main/res/xml/prefs_main.xml index 5ce0d818e..81d1e9a79 100644 --- a/android/app/src/main/res/xml/prefs_main.xml +++ b/android/app/src/main/res/xml/prefs_main.xml @@ -112,6 +112,13 @@ app:singleLineTitle="false" android:persistent="false" android:order="18"/> +