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.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
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.init.AppCenterInitializer;
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.utils.Logger;
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.schedulers.Schedulers;
import java.util.concurrent.Callable;
public class MainActivity extends AppCompatActivity {
private Disposable mOpen5eImportDisposable;
private Disposable mGitImportDisposable;
private Disposable mImportDisposable;
private ImportSource mCurrentImportSource;
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
@@ -212,83 +215,73 @@ public class MainActivity extends AppCompatActivity {
}
}
public void importAllFromOpen5e() {
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) {
ToastHelper.showShort(this, "Open5e import already running");
public void startImportFromSource(ImportSource source) {
if (mImportDisposable != null && !mImportDisposable.isDisposed()) {
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;
}
executeImportFromSource(source);
}
private void executeImportFromSource(ImportSource source) {
mCurrentImportSource = source;
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 -> {
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) {
mOpen5eImportDisposable.dispose();
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_cancelled);
if (mImportDisposable != null && !mImportDisposable.isDisposed()) {
mImportDisposable.dispose();
SnackbarHelper.showLong(rootView, getString(R.string.snackbar_import_source_cancelled, source.projectName));
}
});
snackbar.show();
mOpen5eImportDisposable = Single.fromCallable(() -> {
int totalImported = 0;
String nextUrl = null;
do {
if (mOpen5eImportDisposable != null && mOpen5eImportDisposable.isDisposed()) {
break;
}
Open5eApiWrapper.Open5ePageResult page = Open5eApiWrapper.fetchPage(nextUrl);
for (Monster monster : page.monsters) {
if (mOpen5eImportDisposable != null && mOpen5eImportDisposable.isDisposed()) {
break;
Callable<Integer> importTask;
if (source.importType == ImportSource.ImportType.OPEN5E_API) {
importTask = () -> {
int totalImported = 0;
String nextUrl = null;
do {
if (mImportDisposable != null && mImportDisposable.isDisposed()) break;
Open5eApiWrapper.Open5ePageResult page = Open5eApiWrapper.fetchPage(nextUrl);
for (Monster monster : page.monsters) {
if (mImportDisposable != null && mImportDisposable.isDisposed()) 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 {
((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."));
}
}
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;
nextUrl = page.nextUrl;
} while (nextUrl != null && !nextUrl.isEmpty());
return totalImported;
};
} else {
importTask = () -> GitRepoImporterService.importFromGitRepository(getApplicationContext(), source, () -> mImportDisposable != null && mImportDisposable.isDisposed());
}
View rootView = findViewById(android.R.id.content);
Snackbar snackbar = SnackbarHelper.makeIndefinite(rootView, R.string.snackbar_importing_github);
snackbar.show();
mGitImportDisposable = Single.fromCallable(() ->
GitRepoImporterService.importFromGitRepository(getApplicationContext(), source)
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resultCount -> {
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);
});
mImportDisposable = Single.fromCallable(importTask)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resultCount -> {
snackbar.dismiss();
SnackbarHelper.showLong(rootView, getString(R.string.snackbar_import_source_complete, resultCount, source.projectName));
}, throwable -> {
snackbar.dismiss();
Logger.logError("Failed to import from source: " + source.projectName, throwable);
SnackbarHelper.showLong(rootView, getString(R.string.snackbar_import_source_failed, source.projectName));
});
}
public void importMultipleFilesFromUris(@NonNull List<Uri> uris) {
@@ -464,11 +457,8 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) {
mOpen5eImportDisposable.dispose();
}
if (mGitImportDisposable != null && !mGitImportDisposable.isDisposed()) {
mGitImportDisposable.dispose();
if (mImportDisposable != null && !mImportDisposable.isDisposed()) {
mImportDisposable.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.data.MonsterRepository;
import com.majinnaibu.monstercards.models.GitRepositorySource;
import com.majinnaibu.monstercards.models.ImportSource;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.utils.Logger;
@@ -24,23 +24,28 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.function.BooleanSupplier;
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;
File tempZip = File.createTempFile("repo_download", ".zip", context.getCacheDir());
File extractDir = new File(context.getCacheDir(), "repo_extracted_" + System.currentTimeMillis());
try {
downloadZip(source.downloadUrl, tempZip);
downloadZip(source.downloadUrl, tempZip, isCancelled);
if (isCancelled.getAsBoolean()) return 0;
if (!extractDir.exists()) {
extractDir.mkdirs();
}
unzipAndFilter(tempZip, extractDir, source.fileExtension);
unzipAndFilter(tempZip, extractDir, source.fileExtension, isCancelled);
if (isCancelled.getAsBoolean()) return 0;
// Instantiate Importer dynamically
Class<?> clazz = Class.forName(source.importerClassName);
@@ -50,7 +55,7 @@ public class GitRepoImporterService {
MonsterRepository repository = ((MonsterCardsApplication) context.getApplicationContext()).getMonsterRepository();
// Process files
totalImported = processDirectory(extractDir, importer, repository);
totalImported = processDirectory(extractDir, importer, repository, isCancelled);
} finally {
deleteFileOrDir(tempZip);
@@ -60,7 +65,7 @@ public class GitRepoImporterService {
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);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
@@ -86,27 +91,29 @@ public class GitRepoImporterService {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) != -1) {
if (isCancelled.getAsBoolean()) break;
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))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (isCancelled.getAsBoolean()) break;
if (!entry.isDirectory()) {
String name = entry.getName();
if (filterExtension == null || name.endsWith(filterExtension)) {
File outFile = new File(extractDir, new File(name).getName());
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());
}
try (FileOutputStream fos = new FileOutputStream(outFile)) {
byte[] buffer = new byte[8192];
int count;
while ((count = zis.read(buffer)) != -1) {
if (isCancelled.getAsBoolean()) break;
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;
File[] files = dir.listFiles();
if (files == null) return 0;
for (File file : files) {
if (isCancelled.getAsBoolean()) break;
if (file.isFile()) {
String content = readFileContent(file);
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.ToastHelper;
import com.majinnaibu.monstercards.data.GitRepositoryConfig;
import com.majinnaibu.monstercards.models.GitRepositorySource;
import com.majinnaibu.monstercards.data.ImportConfig;
import com.majinnaibu.monstercards.models.ImportSource;
import java.io.BufferedReader;
import java.io.InputStream;
@@ -159,24 +159,17 @@ public class MCFragment extends Fragment {
dialog.dismiss();
});
view.findViewById(R.id.button_import_open5e).setOnClickListener(v -> {
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importAllFromOpen5e();
}
view.findViewById(R.id.button_import_source).setOnClickListener(v -> {
dialog.dismiss();
});
view.findViewById(R.id.button_import_github).setOnClickListener(v -> {
dialog.dismiss();
showGithubRepoPicker();
showSourcePicker();
});
dialog.show();
}
private void showGithubRepoPicker() {
private void showSourcePicker() {
Context context = requireContext();
List<GitRepositorySource> sources = GitRepositoryConfig.SOURCES;
List<ImportSource> sources = ImportConfig.SOURCES;
if (sources.isEmpty()) {
return;
}
@@ -187,11 +180,11 @@ public class MCFragment extends Fragment {
}
new AlertDialog.Builder(context)
.setTitle(R.string.dialog_select_github_repo)
.setTitle(R.string.dialog_select_import_source)
.setItems(repoNames, (d, which) -> {
GitRepositorySource selected = sources.get(which);
ImportSource selected = sources.get(which);
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importFromGitRepository(selected);
((MainActivity) getActivity()).startImportFromSource(selected);
}
})
.setNegativeButton(R.string.dialog_cancel, null)

View File

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

View File

@@ -163,7 +163,7 @@
<string name="snackbar_dashboard_cleared">Dashboard cleared</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_open5e">From Open5e.com</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_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_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_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="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>