Unified Import Source configuration, added concurrency prevention and combined UI

This commit is contained in:
2026-09-21 13:24:29 -07:00
committed by Tom
parent cb26cec7bc
commit f74e21e7d9
9 changed files with 165 additions and 160 deletions

View File

@@ -10,6 +10,7 @@ import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets; import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
@@ -33,7 +34,7 @@ import com.majinnaibu.monstercards.importers.GitRepoImporterService;
import com.majinnaibu.monstercards.importers.Open5eApiWrapper; import com.majinnaibu.monstercards.importers.Open5eApiWrapper;
import com.majinnaibu.monstercards.init.AppCenterInitializer; import com.majinnaibu.monstercards.init.AppCenterInitializer;
import com.majinnaibu.monstercards.models.BinderExport; import com.majinnaibu.monstercards.models.BinderExport;
import com.majinnaibu.monstercards.models.GitRepositorySource; import com.majinnaibu.monstercards.models.ImportSource;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
import com.majinnaibu.monstercards.utils.SnackbarHelper; import com.majinnaibu.monstercards.utils.SnackbarHelper;
@@ -53,10 +54,12 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import java.util.concurrent.Callable;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private Disposable mOpen5eImportDisposable; private Disposable mImportDisposable;
private Disposable mGitImportDisposable; private ImportSource mCurrentImportSource;
@Override @Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) { public boolean onOptionsItemSelected(@NonNull MenuItem item) {
@@ -212,83 +215,73 @@ public class MainActivity extends AppCompatActivity {
} }
} }
public void importAllFromOpen5e() { public void startImportFromSource(ImportSource source) {
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) { if (mImportDisposable != null && !mImportDisposable.isDisposed()) {
ToastHelper.showShort(this, "Open5e import already running"); new AlertDialog.Builder(this)
.setTitle(R.string.dialog_import_already_running_title)
.setMessage(getString(R.string.dialog_import_already_running_message, mCurrentImportSource != null ? mCurrentImportSource.projectName : ""))
.setPositiveButton(R.string.action_cancel_current, (dialog, which) -> {
mImportDisposable.dispose();
executeImportFromSource(source);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
return; return;
} }
executeImportFromSource(source);
}
private void executeImportFromSource(ImportSource source) {
mCurrentImportSource = source;
View rootView = findViewById(android.R.id.content); View rootView = findViewById(android.R.id.content);
Snackbar snackbar = SnackbarHelper.makeIndefinite(rootView, R.string.snackbar_importing_open5e); Snackbar snackbar = SnackbarHelper.makeIndefinite(rootView, getString(R.string.snackbar_importing_source, source.projectName));
snackbar.setAction(R.string.action_cancel, v -> { snackbar.setAction(R.string.action_cancel, v -> {
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) { if (mImportDisposable != null && !mImportDisposable.isDisposed()) {
mOpen5eImportDisposable.dispose(); mImportDisposable.dispose();
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_cancelled); SnackbarHelper.showLong(rootView, getString(R.string.snackbar_import_source_cancelled, source.projectName));
} }
}); });
snackbar.show(); snackbar.show();
mOpen5eImportDisposable = Single.fromCallable(() -> { Callable<Integer> importTask;
int totalImported = 0; if (source.importType == ImportSource.ImportType.OPEN5E_API) {
String nextUrl = null; importTask = () -> {
do { int totalImported = 0;
if (mOpen5eImportDisposable != null && mOpen5eImportDisposable.isDisposed()) { String nextUrl = null;
break; do {
} if (mImportDisposable != null && mImportDisposable.isDisposed()) break;
Open5eApiWrapper.Open5ePageResult page = Open5eApiWrapper.fetchPage(nextUrl); Open5eApiWrapper.Open5ePageResult page = Open5eApiWrapper.fetchPage(nextUrl);
for (Monster monster : page.monsters) { for (Monster monster : page.monsters) {
if (mOpen5eImportDisposable != null && mOpen5eImportDisposable.isDisposed()) { if (mImportDisposable != null && mImportDisposable.isDisposed()) break;
break; try {
((MonsterCardsApplication) getApplication()).getMonsterRepository()
.saveMonster(monster)
.blockingAwait();
totalImported++;
} catch (Exception e) {
Logger.logError("Failed to save monster to database", e);
runOnUiThread(() -> ToastHelper.showShort(MainActivity.this, "An error occurred while saving a monster."));
}
} }
try { nextUrl = page.nextUrl;
((MonsterCardsApplication) getApplication()).getMonsterRepository() } while (nextUrl != null && !nextUrl.isEmpty());
.saveMonster(monster) return totalImported;
.blockingAwait(); };
totalImported++; } else {
} catch (Exception e) { importTask = () -> GitRepoImporterService.importFromGitRepository(getApplicationContext(), source, () -> mImportDisposable != null && mImportDisposable.isDisposed());
Logger.logError("Failed to save monster to database", e);
runOnUiThread(() -> ToastHelper.showShort(MainActivity.this, "An error occurred while saving a monster."));
}
}
nextUrl = page.nextUrl;
} while (nextUrl != null && !nextUrl.isEmpty());
return totalImported;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
snackbar.dismiss();
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_complete);
}, throwable -> {
snackbar.dismiss();
Logger.logError("Failed to import all Open5e monsters", throwable);
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_failed);
});
}
public void importFromGitRepository(@NonNull GitRepositorySource source) {
if (mGitImportDisposable != null && !mGitImportDisposable.isDisposed()) {
ToastHelper.showShort(this, "A GitHub import is already running.");
return;
} }
View rootView = findViewById(android.R.id.content); mImportDisposable = Single.fromCallable(importTask)
Snackbar snackbar = SnackbarHelper.makeIndefinite(rootView, R.string.snackbar_importing_github); .subscribeOn(Schedulers.io())
snackbar.show(); .observeOn(AndroidSchedulers.mainThread())
.subscribe(resultCount -> {
mGitImportDisposable = Single.fromCallable(() -> snackbar.dismiss();
GitRepoImporterService.importFromGitRepository(getApplicationContext(), source) SnackbarHelper.showLong(rootView, getString(R.string.snackbar_import_source_complete, resultCount, source.projectName));
) }, throwable -> {
.subscribeOn(Schedulers.io()) snackbar.dismiss();
.observeOn(AndroidSchedulers.mainThread()) Logger.logError("Failed to import from source: " + source.projectName, throwable);
.subscribe(resultCount -> { SnackbarHelper.showLong(rootView, getString(R.string.snackbar_import_source_failed, source.projectName));
snackbar.dismiss(); });
SnackbarHelper.showLong(rootView, getString(R.string.snackbar_github_import_complete, resultCount, source.projectName));
}, throwable -> {
snackbar.dismiss();
Logger.logError("Failed to import from GitHub repository: " + source.projectName, throwable);
SnackbarHelper.showLong(rootView, R.string.snackbar_github_import_failed);
});
} }
public void importMultipleFilesFromUris(@NonNull List<Uri> uris) { public void importMultipleFilesFromUris(@NonNull List<Uri> uris) {
@@ -464,11 +457,8 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) { if (mImportDisposable != null && !mImportDisposable.isDisposed()) {
mOpen5eImportDisposable.dispose(); mImportDisposable.dispose();
}
if (mGitImportDisposable != null && !mGitImportDisposable.isDisposed()) {
mGitImportDisposable.dispose();
} }
} }
} }

View File

@@ -1,22 +0,0 @@
package com.majinnaibu.monstercards.data;
import com.majinnaibu.monstercards.models.GitRepositorySource;
import java.util.ArrayList;
import java.util.List;
public class GitRepositoryConfig {
public static final List<GitRepositorySource> SOURCES = new ArrayList<>();
static {
SOURCES.add(new GitRepositorySource(
"pf2e_foundry",
"Pathfinder 2e Foundry VTT",
"foundryvtt",
"https://github.com/foundryvtt/pf2e",
"https://github.com/foundryvtt/pf2e/archive/refs/heads/master.zip",
"com.majinnaibu.monstercards.importers.Pf2eImporter",
".json"
));
}
}

View File

@@ -0,0 +1,34 @@
package com.majinnaibu.monstercards.data;
import com.majinnaibu.monstercards.models.ImportSource;
import java.util.ArrayList;
import java.util.List;
public class ImportConfig {
public static final List<ImportSource> SOURCES = new ArrayList<>();
static {
SOURCES.add(new ImportSource(
"open5e",
"Open5e.com System Reference Document",
"Open5e",
"https://open5e.com",
ImportSource.ImportType.OPEN5E_API,
null,
null,
null
));
SOURCES.add(new ImportSource(
"pf2e_foundry",
"Pathfinder 2e Foundry VTT",
"foundryvtt",
"https://github.com/foundryvtt/pf2e",
ImportSource.ImportType.GIT_ARCHIVE,
"https://github.com/foundryvtt/pf2e/archive/refs/heads/master.zip",
"com.majinnaibu.monstercards.importers.Pf2eImporter",
".json"
));
}
}

View File

@@ -7,7 +7,7 @@ import androidx.annotation.NonNull;
import com.majinnaibu.monstercards.MonsterCardsApplication; import com.majinnaibu.monstercards.MonsterCardsApplication;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.GitRepositorySource; import com.majinnaibu.monstercards.models.ImportSource;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
@@ -24,23 +24,28 @@ import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java.util.function.BooleanSupplier;
public class GitRepoImporterService { public class GitRepoImporterService {
public static int importFromGitRepository(Context context, GitRepositorySource source) throws Exception { public static int importFromGitRepository(Context context, ImportSource source, BooleanSupplier isCancelled) throws Exception {
int totalImported = 0; int totalImported = 0;
File tempZip = File.createTempFile("repo_download", ".zip", context.getCacheDir()); File tempZip = File.createTempFile("repo_download", ".zip", context.getCacheDir());
File extractDir = new File(context.getCacheDir(), "repo_extracted_" + System.currentTimeMillis()); File extractDir = new File(context.getCacheDir(), "repo_extracted_" + System.currentTimeMillis());
try { try {
downloadZip(source.downloadUrl, tempZip); downloadZip(source.downloadUrl, tempZip, isCancelled);
if (isCancelled.getAsBoolean()) return 0;
if (!extractDir.exists()) { if (!extractDir.exists()) {
extractDir.mkdirs(); extractDir.mkdirs();
} }
unzipAndFilter(tempZip, extractDir, source.fileExtension); unzipAndFilter(tempZip, extractDir, source.fileExtension, isCancelled);
if (isCancelled.getAsBoolean()) return 0;
// Instantiate Importer dynamically // Instantiate Importer dynamically
Class<?> clazz = Class.forName(source.importerClassName); Class<?> clazz = Class.forName(source.importerClassName);
@@ -50,7 +55,7 @@ public class GitRepoImporterService {
MonsterRepository repository = ((MonsterCardsApplication) context.getApplicationContext()).getMonsterRepository(); MonsterRepository repository = ((MonsterCardsApplication) context.getApplicationContext()).getMonsterRepository();
// Process files // Process files
totalImported = processDirectory(extractDir, importer, repository); totalImported = processDirectory(extractDir, importer, repository, isCancelled);
} finally { } finally {
deleteFileOrDir(tempZip); deleteFileOrDir(tempZip);
@@ -60,7 +65,7 @@ public class GitRepoImporterService {
return totalImported; return totalImported;
} }
private static void downloadZip(String urlStr, File dest) throws IOException { private static void downloadZip(String urlStr, File dest, BooleanSupplier isCancelled) throws IOException {
URL url = new URL(urlStr); URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET"); conn.setRequestMethod("GET");
@@ -86,27 +91,29 @@ public class GitRepoImporterService {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int count; int count;
while ((count = in.read(buffer)) != -1) { while ((count = in.read(buffer)) != -1) {
if (isCancelled.getAsBoolean()) break;
out.write(buffer, 0, count); out.write(buffer, 0, count);
} }
} }
} }
private static void unzipAndFilter(File zipFile, File extractDir, String filterExtension) throws IOException { private static void unzipAndFilter(File zipFile, File extractDir, String filterExtension, BooleanSupplier isCancelled) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) { try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
ZipEntry entry; ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) { while ((entry = zis.getNextEntry()) != null) {
if (isCancelled.getAsBoolean()) break;
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
String name = entry.getName(); String name = entry.getName();
if (filterExtension == null || name.endsWith(filterExtension)) { if (filterExtension == null || name.endsWith(filterExtension)) {
File outFile = new File(extractDir, new File(name).getName()); File outFile = new File(extractDir, new File(name).getName());
if (outFile.exists()) { if (outFile.exists()) {
// in case there are multiple files with same name in different folders
outFile = new File(extractDir, System.currentTimeMillis() + "_" + new File(name).getName()); outFile = new File(extractDir, System.currentTimeMillis() + "_" + new File(name).getName());
} }
try (FileOutputStream fos = new FileOutputStream(outFile)) { try (FileOutputStream fos = new FileOutputStream(outFile)) {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int count; int count;
while ((count = zis.read(buffer)) != -1) { while ((count = zis.read(buffer)) != -1) {
if (isCancelled.getAsBoolean()) break;
fos.write(buffer, 0, count); fos.write(buffer, 0, count);
} }
} }
@@ -117,12 +124,13 @@ public class GitRepoImporterService {
} }
} }
private static int processDirectory(File dir, EntityImporter<Monster> importer, MonsterRepository repository) { private static int processDirectory(File dir, EntityImporter<Monster> importer, MonsterRepository repository, BooleanSupplier isCancelled) {
int importedCount = 0; int importedCount = 0;
File[] files = dir.listFiles(); File[] files = dir.listFiles();
if (files == null) return 0; if (files == null) return 0;
for (File file : files) { for (File file : files) {
if (isCancelled.getAsBoolean()) break;
if (file.isFile()) { if (file.isFile()) {
String content = readFileContent(file); String content = readFileContent(file);
if (content != null && importer.canImport(content)) { if (content != null && importer.canImport(content)) {

View File

@@ -1,21 +0,0 @@
package com.majinnaibu.monstercards.models;
public class GitRepositorySource {
public String id;
public String projectName;
public String creatorGithubName;
public String creatorPageLink;
public String downloadUrl;
public String importerClassName;
public String fileExtension;
public GitRepositorySource(String id, String projectName, String creatorGithubName, String creatorPageLink, String downloadUrl, String importerClassName, String fileExtension) {
this.id = id;
this.projectName = projectName;
this.creatorGithubName = creatorGithubName;
this.creatorPageLink = creatorPageLink;
this.downloadUrl = downloadUrl;
this.importerClassName = importerClassName;
this.fileExtension = fileExtension;
}
}

View File

@@ -0,0 +1,30 @@
package com.majinnaibu.monstercards.models;
public class ImportSource {
public enum ImportType {
GIT_ARCHIVE,
OPEN5E_API
}
public String id;
public String projectName;
public String creatorName;
public String creatorPageLink;
public ImportType importType;
// Optional attributes specific to Git archive
public String downloadUrl;
public String importerClassName;
public String fileExtension;
public ImportSource(String id, String projectName, String creatorName, String creatorPageLink, ImportType importType, String downloadUrl, String importerClassName, String fileExtension) {
this.id = id;
this.projectName = projectName;
this.creatorName = creatorName;
this.creatorPageLink = creatorPageLink;
this.importType = importType;
this.downloadUrl = downloadUrl;
this.importerClassName = importerClassName;
this.fileExtension = fileExtension;
}
}

View File

@@ -36,8 +36,8 @@ import com.majinnaibu.monstercards.utils.Logger;
import com.majinnaibu.monstercards.utils.SnackbarHelper; import com.majinnaibu.monstercards.utils.SnackbarHelper;
import com.majinnaibu.monstercards.utils.ToastHelper; import com.majinnaibu.monstercards.utils.ToastHelper;
import com.majinnaibu.monstercards.data.GitRepositoryConfig; import com.majinnaibu.monstercards.data.ImportConfig;
import com.majinnaibu.monstercards.models.GitRepositorySource; import com.majinnaibu.monstercards.models.ImportSource;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStream; import java.io.InputStream;
@@ -159,24 +159,17 @@ public class MCFragment extends Fragment {
dialog.dismiss(); dialog.dismiss();
}); });
view.findViewById(R.id.button_import_open5e).setOnClickListener(v -> { view.findViewById(R.id.button_import_source).setOnClickListener(v -> {
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importAllFromOpen5e();
}
dialog.dismiss(); dialog.dismiss();
}); showSourcePicker();
view.findViewById(R.id.button_import_github).setOnClickListener(v -> {
dialog.dismiss();
showGithubRepoPicker();
}); });
dialog.show(); dialog.show();
} }
private void showGithubRepoPicker() { private void showSourcePicker() {
Context context = requireContext(); Context context = requireContext();
List<GitRepositorySource> sources = GitRepositoryConfig.SOURCES; List<ImportSource> sources = ImportConfig.SOURCES;
if (sources.isEmpty()) { if (sources.isEmpty()) {
return; return;
} }
@@ -187,11 +180,11 @@ public class MCFragment extends Fragment {
} }
new AlertDialog.Builder(context) new AlertDialog.Builder(context)
.setTitle(R.string.dialog_select_github_repo) .setTitle(R.string.dialog_select_import_source)
.setItems(repoNames, (d, which) -> { .setItems(repoNames, (d, which) -> {
GitRepositorySource selected = sources.get(which); ImportSource selected = sources.get(which);
if (getActivity() instanceof MainActivity) { if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importFromGitRepository(selected); ((MainActivity) getActivity()).startImportFromSource(selected);
} }
}) })
.setNegativeButton(R.string.dialog_cancel, null) .setNegativeButton(R.string.dialog_cancel, null)

View File

@@ -41,17 +41,10 @@
android:text="@string/action_import_monster_from_file" /> android:text="@string/action_import_monster_from_file" />
<Button <Button
android:id="@+id/button_import_open5e" android:id="@+id/button_import_source"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="@string/action_import_from_open5e" /> android:text="@string/action_import_from_source" />
<Button
android:id="@+id/button_import_github"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/action_import_from_github" />
</LinearLayout> </LinearLayout>

View File

@@ -163,7 +163,7 @@
<string name="snackbar_dashboard_cleared">Dashboard cleared</string> <string name="snackbar_dashboard_cleared">Dashboard cleared</string>
<string name="no_monsters_available">No monsters available in library. Create one first!</string> <string name="no_monsters_available">No monsters available in library. Create one first!</string>
<string name="action_import_from_url">Import URL</string> <string name="action_import_from_url">Import URL</string>
<string name="action_import_from_open5e">From Open5e.com</string>
<string name="dialog_import_title">Import</string> <string name="dialog_import_title">Import</string>
<string name="dialog_import_message">Enter or paste a URL, ID, or data payload:</string> <string name="dialog_import_message">Enter or paste a URL, ID, or data payload:</string>
<string name="dialog_import_url_hint">e.g. D&amp;D Beyond URL or Open5e JSON</string> <string name="dialog_import_url_hint">e.g. D&amp;D Beyond URL or Open5e JSON</string>
@@ -226,14 +226,14 @@
<string name="dialog_remove_all_from_collection_title">Remove All Monsters?</string> <string name="dialog_remove_all_from_collection_title">Remove All Monsters?</string>
<string name="dialog_remove_all_from_collection_message">Are you sure you want to remove all monsters from this collection?</string> <string name="dialog_remove_all_from_collection_message">Are you sure you want to remove all monsters from this collection?</string>
<string name="snackbar_collection_cleared">Removed all monsters from collection</string> <string name="snackbar_collection_cleared">Removed all monsters from collection</string>
<string name="snackbar_importing_open5e">Importing monsters from Open5e…</string> <string name="action_import_from_source">Import from Source Project</string>
<string name="dialog_select_import_source">Select Import Source</string>
<string name="snackbar_importing_source">Importing from %1$s…</string>
<string name="snackbar_import_source_complete">Finished importing %1$d monsters from %2$s.</string>
<string name="snackbar_import_source_failed">Failed to import from %1$s.</string>
<string name="snackbar_import_source_cancelled">Cancelled import from %1$s.</string>
<string name="dialog_import_already_running_title">Import in Progress</string>
<string name="dialog_import_already_running_message">An import from %1$s is currently running. Do you want to cancel it and start a new import?</string>
<string name="action_cancel_current">Cancel Current</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="snackbar_open5e_import_complete">Finished importing Open5e monsters.</string>
<string name="snackbar_open5e_import_cancelled">Cancelled Open5e import.</string>
<string name="snackbar_open5e_import_failed">Failed to import from Open5e.</string>
<string name="action_import_from_github">From GitHub Repository</string>
<string name="dialog_select_github_repo">Select Repository</string>
<string name="snackbar_importing_github">Downloading and importing repository…</string>
<string name="snackbar_github_import_complete">Imported %1$d monsters from %2$s.</string>
<string name="snackbar_github_import_failed">Failed to import from GitHub repository.</string>
</resources> </resources>