Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/res/xml/settings_maintenance.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
android:title="@string/Database_category"
app:iconSpaceReserved="false">

<!-- TODO: Update @string/Maintenance_explanation_summary text to describe SAF-based
import/export (user-chosen locations, system picker). -->
<Preference
android:key="@string/Maintenance_explanation_summary"
android:summary="@string/Maintenance_explanation_summary"
Expand Down
130 changes: 108 additions & 22 deletions app/src/main/org/runnerup/db/DBHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.os.AsyncTask;
import android.util.Log;
import androidx.appcompat.app.AlertDialog;
Expand All @@ -49,6 +50,7 @@

public class DBHelper extends SQLiteOpenHelper implements Constants {

private static final String TAG = "DBHelper";
private static final int DBVERSION = 31;
private static final String DBNAME = "runnerup.db";

Expand Down Expand Up @@ -260,9 +262,7 @@ public void onCreate(SQLiteDatabase arg0) {

@Override
public void onUpgrade(SQLiteDatabase arg0, int oldVersion, int newVersion) {
Log.e(
getClass().getName(),
"onUpgrade: oldVersion: " + oldVersion + ", newVersion: " + newVersion);
Log.e(TAG, "onUpgrade: oldVersion: " + oldVersion + ", newVersion: " + newVersion);

if (oldVersion < 5) {
arg0.execSQL("alter table account add column icon integer");
Expand Down Expand Up @@ -671,60 +671,146 @@ public static String getDbPath(Context ctx) {
return ctx.getFilesDir().getPath() + "/../databases/" + DBNAME;
}

public static Uri getDbUri(Context ctx) {
File dbFile = new File(getDbPath(ctx));

if (!dbFile.exists()) {
return null;
}

return Uri.fromFile(dbFile);
}

public static String getDefaultBackupPath(Context ctx) {
// A path that can be used with SDK 29 scooped storage
return ctx.getExternalFilesDir(null) + File.separator + "runnerup.db.export";
}

public static void importDatabase(Context ctx, String from) {
public static void importDatabase(Context ctx, Uri from) {
final DBHelper mDBHelper = DBHelper.getHelper(ctx);
final SQLiteDatabase db = mDBHelper.getWritableDatabase();
db.close();
mDBHelper.close();

DialogInterface.OnClickListener listener = (dialog, which) -> dialog.dismiss();
AlertDialog.Builder builder = new AlertDialog.Builder(ctx).setTitle("Import " + DBNAME);

if (from == null) {
from = getDefaultBackupPath(ctx);
if (!isValidRunnerUpDatabase(ctx, from)) {
Log.e(TAG, "Selected Uri is not a valid RunnerUp database: " + from);
builder
.setMessage(org.runnerup.common.R.string.import_error_invalid_database)
.setPositiveButton(org.runnerup.common.R.string.OK, listener)
.show();
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(ctx).setTitle("Import " + DBNAME);

// TODO 2: Prompt user that this will overwrite current database
// TODO 3: Backup the current DB before importing

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);
}
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);
}
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;
}
}
}
46 changes: 46 additions & 0 deletions app/src/main/org/runnerup/util/FileUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
}
8 changes: 5 additions & 3 deletions app/src/main/org/runnerup/view/MainLayout.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())) {
Expand All @@ -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);
Expand Down
Loading