From 6d46c61969a621c97d535b6cba560a6f3327b7d8 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Mon, 15 Sep 2025 21:21:16 +0200 Subject: [PATCH 1/3] Implement Storage Access Framework for DB Import/Export This commit transitions database import/export to use the Storage Access Framework (SAF). This allows users to choose export locations outside of app-private storage. Key Changes: - SettingsMaintenanceFragment now uses ActivityResultLauncher with Intent.ACTION_CREATE_DOCUMENT for export and Intent.ACTION_OPEN_DOCUMENT for import, allowing users to select file locations using the system file picker. - DBHelper.importDatabase and DBHelper.exportDatabase now accept Uri parameters (Uri is returned from system file picker). - DBHelper.getDbUri method added to retrieve the RunnerUp database file as a Uri. - FileUtil.copyFile is overloaded to support copying content between Uris using ContentResolver. - Added new string resources for import/export cancellation messages. --- app/res/xml/settings_maintenance.xml | 2 + app/src/main/org/runnerup/db/DBHelper.java | 58 ++++++---- app/src/main/org/runnerup/util/FileUtil.java | 46 ++++++++ .../main/org/runnerup/view/MainLayout.java | 8 +- .../view/SettingsMaintenanceFragment.java | 103 +++++++++++++++++- common/src/main/res/values/strings.xml | 3 + 6 files changed, 191 insertions(+), 29 deletions(-) diff --git a/app/res/xml/settings_maintenance.xml b/app/res/xml/settings_maintenance.xml index fd260ae09..d4d8db484 100644 --- a/app/res/xml/settings_maintenance.xml +++ b/app/res/xml/settings_maintenance.xml @@ -25,6 +25,8 @@ android:title="@string/Database_category" app:iconSpaceReserved="false"> + dialog.dismiss(); if (from == null) { - from = getDefaultBackupPath(ctx); + Log.e(TAG, "Source URI is null for import."); + return; } + + // TODO 1: Test if selected Uri is an actual RunnerUp SQLite database + // TODO 2: Prompt user that this will overwrite current database + // TODO 3: Backup the current DB before importing + AlertDialog.Builder builder = new AlertDialog.Builder(ctx).setTitle("Import " + DBNAME); try { - String to = getDbPath(ctx); - int cnt = FileUtil.copyFile(to, from); + Uri to = getDbUri(ctx); + int cnt = FileUtil.copyFile(ctx, to, from); builder - .setMessage("Copied " + cnt + " bytes from " + from + "\n\nRestart to use the database") + .setMessage( + "Copied " + + cnt + + " bytes from " + + Uri.decode(from.toString()) + + "\n\nRestart to use the database") .setPositiveButton(org.runnerup.common.R.string.OK, listener); - } catch (IOException e) { + } catch (IOException | NullPointerException e) { builder .setMessage("Exception: " + e + " for " + from) .setNegativeButton(org.runnerup.common.R.string.Cancel, listener); @@ -702,25 +723,22 @@ public static void importDatabase(Context ctx, String from) { builder.show(); } - public static void exportDatabase(Context ctx, String to) { + public static void exportDatabase(Context ctx, Uri to) { DialogInterface.OnClickListener listener = (dialog, which) -> dialog.dismiss(); if (to == null) { - to = getDefaultBackupPath(ctx); + Log.e(TAG, "Destination URI is null for export."); + return; } + AlertDialog.Builder builder = new AlertDialog.Builder(ctx).setTitle("Export " + DBNAME); try { - String from = getDbPath(ctx); - int cnt = FileUtil.copyFile(to, from); + Uri from = getDbUri(ctx); + int cnt = FileUtil.copyFile(ctx, to, from); builder - .setMessage( - "Exported " - + cnt - + " bytes to " - + to - + "\n\nNote that the file will be deleted at uninstall") + .setMessage("Exported " + cnt + " bytes to " + Uri.decode(to.toString())) .setPositiveButton(org.runnerup.common.R.string.OK, listener); - } catch (IOException e) { + } catch (IOException | NullPointerException e) { builder .setMessage("Exception: " + e + " for " + to) .setNegativeButton(org.runnerup.common.R.string.Cancel, listener); diff --git a/app/src/main/org/runnerup/util/FileUtil.java b/app/src/main/org/runnerup/util/FileUtil.java index 1ad60709b..1c3895f1e 100644 --- a/app/src/main/org/runnerup/util/FileUtil.java +++ b/app/src/main/org/runnerup/util/FileUtil.java @@ -17,6 +17,10 @@ package org.runnerup.util; +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.ParcelFileDescriptor; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; @@ -79,4 +83,46 @@ public static int copy(InputStream input, String dst) throws IOException { close(output); } } + + /** + * Copies content from a source Uri to a destination Uri using the provided Context. + * + * @param context The Context used to obtain a ContentResolver for opening the Uris. + * @param to The destination Uri. + * @param from The source Uri. + * @return The number of bytes copied. + * @throws IOException If an I/O error occurs, including if Uris cannot be opened (e.g., + * FileNotFoundException). + * @throws NullPointerException if context, to, or from Uri is null. + */ + public static int copyFile(Context context, Uri to, Uri from) throws IOException { + if (context == null) { + throw new NullPointerException("Context cannot be null"); + } + if (to == null) { + throw new NullPointerException("Destination Uri cannot be null"); + } + if (from == null) { + throw new NullPointerException("Source Uri cannot be null"); + } + + // openFileDescriptor will throw FileNotFoundException if the URI cannot be opened, + // which will be propagated up. + ContentResolver resolver = context.getContentResolver(); + try (ParcelFileDescriptor fromFileDescriptor = resolver.openFileDescriptor(from, "r"); + ParcelFileDescriptor toFileDescriptor = resolver.openFileDescriptor(to, "w")) { + + if (fromFileDescriptor == null) { + throw new IOException("Could not open source Uri: " + from); + } + if (toFileDescriptor == null) { + throw new IOException("Could not open destination Uri: " + to); + } + + try (FileInputStream input = new FileInputStream(fromFileDescriptor.getFileDescriptor()); + FileOutputStream output = new FileOutputStream(toFileDescriptor.getFileDescriptor())) { + return copy(input, output); + } + } + } } diff --git a/app/src/main/org/runnerup/view/MainLayout.java b/app/src/main/org/runnerup/view/MainLayout.java index 34c71d692..b655bf22e 100644 --- a/app/src/main/org/runnerup/view/MainLayout.java +++ b/app/src/main/org/runnerup/view/MainLayout.java @@ -28,7 +28,6 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.AssetManager; import android.content.res.Resources; -import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.os.Bundle; @@ -167,7 +166,10 @@ public void onCreate(Bundle savedInstanceState) { handleBundled(getApplicationContext().getAssets(), "bundled", getFilesDir().getPath() + "/.."); // if we were called from an intent-filter because user opened "runnerup.db.export", load it - final String filePath; + // TODO: Add/Update intent-filter in Manifest to handle ACTION_VIEW for 'content' (and + // optionally 'file') schemes with appropriate MIME and path, but only after implementing + // "overwrite protection" when importing (prompt user to confirm overwrite). + /*final String filePath; final Uri data = getIntent().getData(); if (data != null) { if ("content".equals(data.getScheme())) { @@ -193,7 +195,7 @@ public void onCreate(Bundle savedInstanceState) { // No check for permissions or that this is within scooped storage (>=SDK29) Log.i(getClass().getSimpleName(), "Importing database from " + filePath); DBHelper.importDatabase(MainLayout.this, filePath); - } + }*/ // Apply system bars insets to avoid UI overlap ViewUtil.Insets(findViewById(R.id.main_root), true); diff --git a/app/src/main/org/runnerup/view/SettingsMaintenanceFragment.java b/app/src/main/org/runnerup/view/SettingsMaintenanceFragment.java index b3eac21c1..1c14cf999 100644 --- a/app/src/main/org/runnerup/view/SettingsMaintenanceFragment.java +++ b/app/src/main/org/runnerup/view/SettingsMaintenanceFragment.java @@ -1,8 +1,18 @@ package org.runnerup.view; +import android.app.Activity; import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; import android.content.res.Resources; +import android.net.Uri; import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import java.util.Locale; @@ -11,6 +21,8 @@ public class SettingsMaintenanceFragment extends PreferenceFragmentCompat { + private static final String TAG = "SettingsMaintenance"; + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { setPreferencesFromResource(R.xml.settings_maintenance, rootKey); @@ -37,18 +49,80 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { path)); } + /** + * ActivityResultLauncher for handling the result of the {@link Intent#ACTION_CREATE_DOCUMENT} + * intent used for exporting the database. + * + *

When the user selects a destination file, this launcher receives the {@link Uri} and + * initiates the database export process via {@link DBHelper#exportDatabase(Context, Uri)}. If the + * export is cancelled or no Uri is returned, a toast message is shown and a warning is logged. + */ + private final ActivityResultLauncher exportDbLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + Uri toUri = getUriFromResult(result); + if (toUri != null) { + DBHelper.exportDatabase(requireContext(), toUri); + } else { + Toast.makeText( + requireContext(), + org.runnerup.common.R.string.export_cancelled, + Toast.LENGTH_SHORT) + .show(); + Log.w(TAG, "Export cancelled or URI not found."); + } + }); + + /** + * ActivityResultLauncher for handling the result of the {@link Intent#ACTION_OPEN_DOCUMENT} + * intent used for importing the database. + * + *

When the user selects a database file, this launcher receives its {@link Uri} and initiates + * the database import process via {@link DBHelper#importDatabase(Context, Uri)}. If the import is + * cancelled or no Uri is returned, a toast message is shown and a warning is logged. + */ + private final ActivityResultLauncher importDbLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + Uri fromUri = getUriFromResult(result); + if (fromUri != null) { + DBHelper.importDatabase(requireContext(), fromUri); + } else { + Toast.makeText( + requireContext(), + org.runnerup.common.R.string.import_cancelled, + Toast.LENGTH_SHORT) + .show(); + Log.w(TAG, "Import cancelled or URI not found."); + } + }); + private final Preference.OnPreferenceClickListener onExportClick = preference -> { - // TODO Use picker with ACTION_CREATE_DOCUMENT - DBHelper.exportDatabase(requireContext(), null); - return false; + Intent intent = + new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + // Use "application/octet-stream" to be consistent with the mime type used in + // the http intent-filter for MainLayout (in AndroidManifest). + .setType("application/octet-stream") + .putExtra( + Intent.EXTRA_TITLE, + "runnerup.db.export"); // Suggest a name (note: user may change it) + exportDbLauncher.launch(intent); + return true; }; private final Preference.OnPreferenceClickListener onImportClick = preference -> { - // TODO Use picker with ACTION_OPEN_DOCUMENT - DBHelper.importDatabase(requireContext(), null); - return false; + Intent intent = + new Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/octet-stream") + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); + importDbLauncher.launch(intent); + return true; }; private final Preference.OnPreferenceClickListener onPruneClick = @@ -59,4 +133,21 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { DBHelper.purgeDeletedActivities(requireContext(), dialog, dialog::dismiss); return false; }; + + /** + * Helper method to check the result of an ActivityResult and extract the Uri. + * + * @param result The ActivityResult from the launcher. + * @return The Uri if the result was OK and data is present, otherwise null. + */ + @Nullable + private Uri getUriFromResult(ActivityResult result) { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null && data.getData() != null) { + return data.getData(); + } + } + return null; + } } diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 1049e38ee..368eb53a8 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -372,4 +372,7 @@ Orienteering Walking Treadmill + + Import cancelled + Export cancelled From 21c8302dc3e4962b89e6cef17d6d9af03b3252d9 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Thu, 18 Sep 2025 08:33:30 +0200 Subject: [PATCH 2/3] Validate Uri before database import This commit introduces a validation step to ensure that the selected Uri for database import is a valid RunnerUp SQLite database. --- app/src/main/org/runnerup/db/DBHelper.java | 71 +++++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/app/src/main/org/runnerup/db/DBHelper.java b/app/src/main/org/runnerup/db/DBHelper.java index c1ac8deed..41be84220 100644 --- a/app/src/main/org/runnerup/db/DBHelper.java +++ b/app/src/main/org/runnerup/db/DBHelper.java @@ -694,12 +694,12 @@ public static void importDatabase(Context ctx, Uri from) { DialogInterface.OnClickListener listener = (dialog, which) -> dialog.dismiss(); - if (from == null) { - Log.e(TAG, "Source URI is null for import."); + if (!isValidRunnerUpDatabase(ctx, from)) { + Log.e(TAG, "Selected Uri is not a valid RunnerUp database: " + from); + // TODO: Show error dialog return; } - // TODO 1: Test if selected Uri is an actual RunnerUp SQLite database // TODO 2: Prompt user that this will overwrite current database // TODO 3: Backup the current DB before importing @@ -745,4 +745,69 @@ public static void exportDatabase(Context ctx, Uri to) { } builder.show(); } + + /** + * Checks if the content at the given Uri is a valid RunnerUp SQLite database by attempting to + * open the database and query its schema. + * + * @param context The Context used to obtain a ContentResolver for opening the Uri. + * @param uri The Uri to check. + * @return true if the Uri is a valid RunnerUp database, false otherwise. + */ + private static boolean isValidRunnerUpDatabase(Context context, Uri uri) { + if (uri == null || context == null) { + return false; + } + + File tempDbFile = null; + SQLiteDatabase tempDb = null; + + try { + // 1. Create a temp file in the app's cache directory, and copy the content of the Uri to it + tempDbFile = File.createTempFile("import_test", ".db", context.getCacheDir()); + int cnt = FileUtil.copyFile(context, Uri.fromFile(tempDbFile), uri); + Log.d(TAG, "Copied " + cnt + " bytes to temporary db file."); + + // 2. Open the temporary file as an SQLite database + tempDb = + SQLiteDatabase.openDatabase( + tempDbFile.getAbsolutePath(), null, SQLiteDatabase.OPEN_READONLY); + + // 3. Assume valid if a table named "activity" exists + return tableExists(tempDb, DB.ACTIVITY.TABLE); + + } catch (Exception e) { + Log.e(TAG, "Unexpected error during db validation. URI: " + uri); + return false; + } finally { + if (tempDb != null && tempDb.isOpen()) { + tempDb.close(); + } + + if (tempDbFile != null && tempDbFile.exists()) { + tempDbFile.delete(); // ignore result, assume deleted + } + } + } + + /** Checks if a table exists in the given database (that needs to be in a opened state). */ + private static boolean tableExists(SQLiteDatabase db, String tableName) { + if (tableName == null || db == null || !db.isOpen()) { + return false; + } + + try (Cursor cursor = + db.rawQuery( + "SELECT COUNT(*) FROM sqlite_master WHERE type = ? AND name = ?", + new String[] {"table", tableName})) { + if (cursor.moveToFirst()) { + return cursor.getInt(0) > 0; + } + + return false; + } catch (Exception e) { + Log.e(TAG, "Error checking if RunnerUp database table named \"" + tableName + "\" exists."); + return false; + } + } } From 2690c3ff4833d263f1a844a2c9c28e7d47aabf51 Mon Sep 17 00:00:00 2001 From: Robert Jonsson Date: Thu, 18 Sep 2025 08:47:35 +0200 Subject: [PATCH 3/3] Show error dialog on invalid database import This commit introduces an error dialog that is displayed to the user when they attempt to import an invalid RunnerUp database file. A new string resource import_error_invalid_database was added for the dialog message. --- app/src/main/org/runnerup/db/DBHelper.java | 7 +++++-- common/src/main/res/values/strings.xml | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/org/runnerup/db/DBHelper.java b/app/src/main/org/runnerup/db/DBHelper.java index 41be84220..74712d1f2 100644 --- a/app/src/main/org/runnerup/db/DBHelper.java +++ b/app/src/main/org/runnerup/db/DBHelper.java @@ -693,17 +693,20 @@ public static void importDatabase(Context ctx, Uri from) { mDBHelper.close(); DialogInterface.OnClickListener listener = (dialog, which) -> dialog.dismiss(); + AlertDialog.Builder builder = new AlertDialog.Builder(ctx).setTitle("Import " + DBNAME); if (!isValidRunnerUpDatabase(ctx, from)) { Log.e(TAG, "Selected Uri is not a valid RunnerUp database: " + from); - // TODO: Show error dialog + builder + .setMessage(org.runnerup.common.R.string.import_error_invalid_database) + .setPositiveButton(org.runnerup.common.R.string.OK, listener) + .show(); return; } // TODO 2: Prompt user that this will overwrite current database // TODO 3: Backup the current DB before importing - AlertDialog.Builder builder = new AlertDialog.Builder(ctx).setTitle("Import " + DBNAME); try { Uri to = getDbUri(ctx); int cnt = FileUtil.copyFile(ctx, to, from); diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 368eb53a8..22731d8f3 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -375,4 +375,5 @@ Import cancelled Export cancelled + Selected file is not a valid RunnerUp database!