diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce43f63..7fda904 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,7 @@ + @@ -71,6 +72,7 @@ + diff --git a/app/src/main/java/org/dharmaseed/android/DBManager.java b/app/src/main/java/org/dharmaseed/android/DBManager.java index 4034984..6f8faec 100644 --- a/app/src/main/java/org/dharmaseed/android/DBManager.java +++ b/app/src/main/java/org/dharmaseed/android/DBManager.java @@ -25,12 +25,15 @@ import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import androidx.annotation.NonNull; + +import android.net.Uri; import android.util.Log; import org.json.JSONException; import org.json.JSONObject; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -88,16 +91,18 @@ protected void copyAssetDB(File destFile) throws IOException { InputStream dbIn = context.getAssets().open(DB_NAME); destFile.getParentFile().mkdirs(); OutputStream dbOut = new FileOutputStream(destFile); + copyStreams(dbIn, dbOut); + } + private void copyStreams(InputStream src, OutputStream dest) throws IOException { byte[] buf = new byte[1024]; int len; - while ((len = dbIn.read(buf)) > 0) { - dbOut.write(buf, 0, len); + while ((len = src.read(buf)) > 0) { + dest.write(buf, 0, len); } - - dbOut.flush(); - dbOut.close(); - dbIn.close(); + dest.flush(); + src.close(); + dest.close(); } public static synchronized DBManager getInstance(Context context) { @@ -415,6 +420,101 @@ public boolean shouldSync() { return outOfDate; } + public void exportUserTablesToUri(Uri uri) throws IOException { + File tempFile = File.createTempFile("export", ".db", context.getCacheDir()); + SQLiteDatabase targetDb = SQLiteDatabase.openOrCreateDatabase(tempFile, null); + File sourceDbPath = context.getDatabasePath(DB_NAME); + + String[][] userTableSpec = { + {C.TalkStars.TABLE_NAME, C.TalkStars.CREATE_TABLE}, + {C.TeacherStars.TABLE_NAME, C.TeacherStars.CREATE_TABLE}, + {C.CenterStars.TABLE_NAME, C.CenterStars.CREATE_TABLE}, + {C.TalkHistory.TABLE_NAME, C.TalkHistory.CREATE_TABLE} + }; + for (String [] userTable: userTableSpec) { + String tableName = userTable[0], tableCreate = userTable[1]; + targetDb.execSQL(tableCreate); + targetDb.execSQL( + "ATTACH DATABASE ? AS source_db", + new Object[]{sourceDbPath} + ); + + // Copy everything in one go + targetDb.execSQL( + "INSERT INTO main." + tableName + " SELECT * FROM source_db." + tableName + ); + + // Detach again + targetDb.execSQL("DETACH DATABASE source_db"); + } + targetDb.close(); + + // 4. Write DB file to SAF Uri + copyStreams( + new FileInputStream(tempFile), + context.getContentResolver().openOutputStream(uri) + ); + + tempFile.delete(); + } + + public void importUserTablesFromUri(Uri uri) throws IOException { + SQLiteDatabase db = getWritableDatabase(); + // Create a temporary file to hold the database being imported + File tempFile = File.createTempFile("import", ".db", context.getCacheDir()); + + try { + // 1. Copy URI content to a temporary file using the existing copyStreams utility + InputStream is = context.getContentResolver().openInputStream(uri); + if (is == null) throw new IOException("Could not open input stream from URI: " + uri); + copyStreams(is, new FileOutputStream(tempFile)); + + // 2. Attach the temporary database + db.execSQL("ATTACH DATABASE '" + tempFile.getAbsolutePath() + "' AS import_db"); + + try { + db.beginTransaction(); + + // 3. Handle the three Stars tables (Union) + // Since these only have one column (_id), INSERT OR IGNORE effectively performs a union + String[] starTables = { + C.TalkStars.TABLE_NAME, + C.TeacherStars.TABLE_NAME, + C.CenterStars.TABLE_NAME + }; + + for (String table : starTables) { + db.execSQL("INSERT OR IGNORE INTO main." + table + " SELECT * FROM import_db." + table); + } + + // A. Delete local rows where the imported database has a more recent DATE_TIME + final String historyTable = C.TalkHistory.TABLE_NAME; + final String idCol = C.TalkHistory.ID; + final String dateCol = C.TalkHistory.DATE_TIME; + + db.execSQL("DELETE FROM main." + historyTable + " WHERE " + idCol + " IN (" + + "SELECT imported." + idCol + " FROM import_db." + historyTable + " AS imported " + + "WHERE imported." + dateCol + " > main." + historyTable + "." + dateCol + ")"); + + // B. Insert all rows from the imported table that don't exist locally + // (This includes the ones we just deleted and brand new ones) + db.execSQL("INSERT OR IGNORE INTO main." + historyTable + + " SELECT * FROM import_db." + historyTable); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + // 5. Detach the database + db.execSQL("DETACH DATABASE import_db"); + } + } finally { + // Ensure the temporary file is cleaned up + if (tempFile.exists()) { + tempFile.delete(); + } + } + } + /** * Get an alias for a fully qualified column name. This is useful in naming columns in a query * using SQL AS clauses and referencing them later diff --git a/app/src/main/java/org/dharmaseed/android/NavigationActivity.java b/app/src/main/java/org/dharmaseed/android/NavigationActivity.java index 0b76f1a..8be85f3 100644 --- a/app/src/main/java/org/dharmaseed/android/NavigationActivity.java +++ b/app/src/main/java/org/dharmaseed/android/NavigationActivity.java @@ -28,6 +28,9 @@ import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.core.content.ContextCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.cursoradapter.widget.CursorAdapter; @@ -35,6 +38,11 @@ import android.text.Html; import android.text.method.LinkMovementMethod; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.ImageSpan; +import androidx.core.content.ContextCompat; +import android.graphics.drawable.Drawable; import android.view.KeyEvent; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; @@ -57,6 +65,7 @@ import android.widget.TextView; import android.widget.Toast; +import java.io.IOException; import java.util.ArrayList; import java.util.LinkedList; import java.util.Arrays; @@ -70,6 +79,7 @@ public class NavigationActivity extends AppCompatActivity View.OnFocusChangeListener { public final static String TALK_DETAIL_EXTRA = "org.dharmaseed.android.TALK_DETAIL"; + private final static String USER_DB_EXPORT_FILE = "dharmaseed_user_data.sqlite3"; NavigationView navigationView; ListView listView; @@ -260,11 +270,7 @@ public void onRefresh() { } }); - DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); - ActionBarDrawerToggle toggle = new ActionBarDrawerToggle( - this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close); - drawer.setDrawerListener(toggle); - toggle.syncState(); + initNavigationDrawer(toolbar); LocalBroadcastManager.getInstance(this).registerReceiver(new BroadcastReceiver() { @Override @@ -287,6 +293,48 @@ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCoun }); } + private void initNavigationDrawer(Toolbar toolbar) { + DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); + ActionBarDrawerToggle toggle = new ActionBarDrawerToggle( + this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close); + drawer.addDrawerListener(toggle); + toggle.syncState(); + + addDBIcons( + navigationView.getMenu().findItem(R.id.nav_export), + getString(R.string.drawer_export) + ); + + addDBIcons( + navigationView.getMenu().findItem(R.id.nav_import), + getString(R.string.drawer_import) + ); + } + + private void addDBIcons(MenuItem item, String baseText) { + if (item == null) return; + SpannableStringBuilder sb = new SpannableStringBuilder(baseText + " and "); + int iconSize = (int) (headerPrimary.getTextSize() * 0.9); + addIconToSpan(sb, R.drawable.ic_history_db, baseText.length() + 1, iconSize); + addIconToSpan(sb, R.drawable.ic_star_db, 99, iconSize); + item.setTitle(sb); + } + + private void addIconToSpan(SpannableStringBuilder sb, int drawableId, int index, int size) { + Drawable drawable = ContextCompat.getDrawable(this, drawableId); + if (drawable != null) { + drawable.setBounds(0, 0, size, size); + + // don't insert beyond the end of the string + if (index >= sb.length()) { + index = sb.length() - 1; + } + + ImageSpan imageSpan = new ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM); + sb.setSpan(imageSpan, index, index + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + private void updateScrollLabel(View item) { int scrollId = -1; ViewMode v = getCurrentViewMode(); @@ -709,12 +757,19 @@ public boolean onNavigationItemSelected(MenuItem item) { // Handle navigation view item clicks here. int id = item.getItemId(); + boolean highlightItem = true; if (id == R.id.nav_talks) { setViewMode(new ViewMode(ViewMode.VIEW_MODE_TALKS)); } else if (id == R.id.nav_teachers) { setViewMode(new ViewMode(ViewMode.VIEW_MODE_TEACHERS)); } else if (id == R.id.nav_centers) { setViewMode(new ViewMode(ViewMode.VIEW_MODE_CENTERS)); + } else if (id == R.id.nav_export) { + startUserDBExport(); + highlightItem = false; + } else if (id == R.id.nav_import) { + startUserDBImport(); + highlightItem = false; } // else if (id == R.id.nav_retreats) { // Intent intent = new Intent(this, RetreatSearchActivity.class); @@ -723,7 +778,7 @@ public boolean onNavigationItemSelected(MenuItem item) { DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); drawer.closeDrawer(GravityCompat.START); - return true; + return highlightItem; } public void headingDetailCollapseExpandButtonClicked(View view) { @@ -868,6 +923,80 @@ private List getSearchTerms() return searchTerms; } + private final ActivityResultLauncher exportDatabaseLauncher = registerForActivityResult( + new ActivityResultContracts.CreateDocument("application/x-sqlite3"), + uri -> { + if (uri != null) { + performUserDBExport(uri); + } + } + ); + + private void performUserDBExport(Uri uri) { + // We use a new thread because DB operations and file copying (I/O) + // in DBManager.exportUserTablesToUri will block the UI thread. + new Thread(() -> { + try { + dbManager.exportUserTablesToUri(uri); + Log.i(LOG_TAG, "Successfully exported user DB to " + uri); + + // Success: Switch back to UI thread to show toast + runOnUiThread(() -> showToast(getString(R.string.export_success))); + } catch (IOException e) { + Log.e(LOG_TAG, "Export failed", e); + + // Error: Switch back to UI thread to show toast + runOnUiThread(() -> showToast(getString(R.string.export_failed))); + } + }).start(); + } + + private void startUserDBExport() { + // The "CreateDocument" contract opens the file picker + // We pass the suggested filename here + exportDatabaseLauncher.launch(USER_DB_EXPORT_FILE); + } + + // 1. Add the launcher for picking a file + private final ActivityResultLauncher importDatabaseLauncher = registerForActivityResult( + new ActivityResultContracts.OpenDocument(), + uri -> { + if (uri != null) { + performUserDBImport(uri); + } + } + ); + + // 2. Implement the background processing logic + private void performUserDBImport(Uri uri) { + // We use a new thread because DB operations and file copying (I/O) + // in DBManager.importUserTablesFromUri will block the UI thread. + new Thread(() -> { + try { + dbManager.importUserTablesFromUri(uri); + Log.i(LOG_TAG, "Successfully imported user DB from " + uri); + + // Success: Switch back to UI thread to refresh data and show toast + runOnUiThread(() -> { + updateDisplayedData(); // Refresh current list to show new stars/history + showToast(getString(R.string.import_success)); + }); + } catch (IOException e) { + Log.e(LOG_TAG, "Import failed", e); + + // Error: Switch back to UI thread to show toast + runOnUiThread(() -> showToast(getString(R.string.import_failed))); + } + }).start(); + } + + // 3. Update the existing startUserDBImport method + public void startUserDBImport() { + // Launches the system file picker to select a SQLite database file + // We accept any file type or specifically application/x-sqlite3 if supported + importDatabaseLauncher.launch(new String[]{"application/x-sqlite3", "application/octet-stream", "*/*"}); + } + /** * Shows a short toast with text=message * @param message diff --git a/app/src/main/res/drawable/ic_export_arrow.xml b/app/src/main/res/drawable/ic_export_arrow.xml new file mode 100644 index 0000000..ae7ec41 --- /dev/null +++ b/app/src/main/res/drawable/ic_export_arrow.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_history_db.xml b/app/src/main/res/drawable/ic_history_db.xml new file mode 100644 index 0000000..8121bcd --- /dev/null +++ b/app/src/main/res/drawable/ic_history_db.xml @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_import_arrow.xml b/app/src/main/res/drawable/ic_import_arrow.xml new file mode 100644 index 0000000..cde20bd --- /dev/null +++ b/app/src/main/res/drawable/ic_import_arrow.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_star_db.xml b/app/src/main/res/drawable/ic_star_db.xml new file mode 100644 index 0000000..2f05341 --- /dev/null +++ b/app/src/main/res/drawable/ic_star_db.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_navigation_drawer.xml b/app/src/main/res/menu/activity_navigation_drawer.xml index ab0fa1a..d1d5330 100644 --- a/app/src/main/res/menu/activity_navigation_drawer.xml +++ b/app/src/main/res/menu/activity_navigation_drawer.xml @@ -44,4 +44,16 @@ android:title="@string/drawer_centers" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 043afb9..1c9c16a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,12 @@ Teachers Centers Retreats + Export + Import + Database exported successfully! 👍 + Export failed. Please try again. 😖 + Database imported successfully! 👍 + Import failed. Please try again. 😖 Retreat Search Play Talk Download Talk