Skip to content
Open
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/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="dharmaseed.org" />
<data android:host="www.dharmaseed.org" />
<data android:path="/teachers/" />
<data android:path="/talks/" />
<data android:pathPattern="/teacher/..*" />
Expand All @@ -71,6 +72,7 @@
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="dharmaseed.org" />
<data android:host="www.dharmaseed.org" />
<data android:pathPattern="/talks/..*" />
</intent-filter>
</activity>
Expand Down
114 changes: 109 additions & 5 deletions app/src/main/java/org/dharmaseed/android/DBManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,16 +91,21 @@ 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, true);
}

private void copyStreams(InputStream src, OutputStream dest, boolean closeAfterCopy) throws IOException {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all uses of this method call it with closeAfterCopy = true. Would it make sense to just remove this parameter?

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);
}
dest.flush();

dbOut.flush();
dbOut.close();
dbIn.close();
if (closeAfterCopy) {
src.close();
dest.close();
}
}

public static synchronized DBManager getInstance(Context context) {
Expand Down Expand Up @@ -415,6 +423,102 @@ 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),
true
);

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), true);

// 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
Expand Down
141 changes: 135 additions & 6 deletions app/src/main/java/org/dharmaseed/android/NavigationActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,21 @@
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;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;

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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In an ideal world, I think it would be nice to be able to embed these icons directly inside of the drawer_import/drawer_export resource strings, so we didn't have to write code to join the icons together with the text. However, I'm not aware of any way to do that, so I think this is the next best approach. 🙂

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();
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -868,6 +923,80 @@ private List<String> getSearchTerms()
return searchTerms;
}

private final ActivityResultLauncher<String> 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<String[]> 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
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/res/drawable/ic_export_arrow.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#757575"
android:pathData="M5,20h14v-2H5V20z M12,4L5,11h4v5h6v-5h4L12,4z" />
</vector>
13 changes: 13 additions & 0 deletions app/src/main/res/drawable/ic_history_db.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#80000000"
android:strokeColor="#00000000"
android:strokeWidth="0.0"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M13,3a9,9 0,0 0,-9 9H1l3.89,3.89 0.07,0.11L9,12H6a7,7 0,1 1,1 3.74l-1.42,1.42A8.98,8.98 0,0 0,13 21a9,9 0,0 0,0 -18zM12,8v5l4.25,2.52 0.75,-1.23 -3.5,-2.09V8z" />
</vector>
Loading