Adds card and binder export and import.

This commit is contained in:
2026-09-16 19:25:32 -07:00
parent ccb06960a2
commit b66b653982
19 changed files with 472 additions and 2 deletions

View File

@@ -48,6 +48,8 @@
<data android:pathPattern=".*\\.monster\\.txt" />
<data android:pathPattern=".*\\.card" />
<data android:pathPattern=".*\\.card\\.txt" />
<data android:pathPattern=".*\\.binder" />
<data android:pathPattern=".*\\.binder\\.txt" />
</intent-filter>
<!-- Intent filter for explicitly sharing files/text to Monster Cards -->

View File

@@ -22,6 +22,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.gson.Gson;
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.importers.BinderImporter;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.init.AppCenterInitializer;
import com.majinnaibu.monstercards.utils.Logger;
@@ -110,6 +111,25 @@ public class MainActivity extends AppCompatActivity {
}
public void importMonsterFromInputAndNavigate(@NonNull String input) {
BinderImporter binderImporter = new BinderImporter();
if (binderImporter.canImport(input)) {
Toast.makeText(this, R.string.toast_importing_url, Toast.LENGTH_SHORT).show();
Single.fromCallable(() -> binderImporter.parse(input))
.flatMapCompletable(binder -> ((MonsterCardsApplication) getApplication()).getMonsterRepository().importBinder(binder))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
Toast.makeText(this, R.string.snackbar_import_binder_success, Toast.LENGTH_LONG).show();
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
NavController navController = navHostFragment.getNavController();
navController.navigate(R.id.navigation_library);
}, throwable -> {
Logger.logError("Failed to import binder from input", throwable);
Toast.makeText(this, R.string.failed_to_import_url, Toast.LENGTH_LONG).show();
});
return;
}
Toast.makeText(this, R.string.toast_importing_url, Toast.LENGTH_SHORT).show();
Single.fromCallable(() -> MonsterImportHelper.fromJSON(input))
.subscribeOn(Schedulers.io())
@@ -143,7 +163,7 @@ public class MainActivity extends AppCompatActivity {
if (uri == null || !isMonsterFile(uri)) {
if (uri != null) {
Logger.logError("Ignored file because extension is not supported (.monster, .card): " + uri);
Logger.logError("Ignored file because extension is not supported (.monster, .card, .binder): " + uri);
}
return null;
}
@@ -162,7 +182,8 @@ public class MainActivity extends AppCompatActivity {
}
String lowerName = fileName.toLowerCase(Locale.ROOT);
return lowerName.endsWith(".monster") || lowerName.endsWith(".monster.txt")
|| lowerName.endsWith(".card") || lowerName.endsWith(".card.txt");
|| lowerName.endsWith(".card") || lowerName.endsWith(".card.txt")
|| lowerName.endsWith(".binder") || lowerName.endsWith(".binder.txt");
}
@Nullable

View File

@@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
import com.majinnaibu.monstercards.AppDatabase;
import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.models.BinderExport;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionMonster;
import com.majinnaibu.monstercards.models.CollectionWithCount;
@@ -12,7 +13,9 @@ import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.SearchResultItem;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@@ -260,6 +263,67 @@ public class MonsterRepository {
return result;
}
public Completable importBinder(@NonNull BinderExport binder) {
return Completable.fromAction(() -> {
if (binder.collections == null || binder.collections.isEmpty()) {
return;
}
for (BinderExport.CollectionExport colExport : binder.collections) {
if (colExport.cards == null || colExport.cards.isEmpty()) {
continue;
}
List<Monster> monstersToSave = new ArrayList<>();
for (Monster card : colExport.cards) {
if (card.id == null) {
card.id = UUID.randomUUID();
}
monstersToSave.add(card);
}
m_db.monsterDAO().save(monstersToSave.toArray(new Monster[0])).blockingAwait();
String colName = colExport.name != null ? colExport.name.trim() : "";
if (!colName.isEmpty()) {
List<Collection> existingCols = m_db.collectionDAO().getAll().first(new ArrayList<>()).blockingGet();
Collection targetCollection = null;
for (Collection existing : existingCols) {
if (existing.name != null && existing.name.equalsIgnoreCase(colName)) {
targetCollection = existing;
break;
}
}
UUID targetCollectionId;
if (targetCollection != null) {
targetCollectionId = targetCollection.id;
} else {
Collection newCol = new Collection();
newCol.id = UUID.randomUUID();
newCol.name = colName;
m_db.collectionDAO().save(newCol).blockingAwait();
targetCollectionId = newCol.id;
}
List<Monster> colMonsters = m_db.collectionDAO().getMonstersForCollection(targetCollectionId.toString())
.first(new ArrayList<>()).blockingGet();
Set<UUID> existingMonsterIds = new HashSet<>();
for (Monster m : colMonsters) {
existingMonsterIds.add(m.id);
}
int ordinal = colMonsters.size();
for (Monster monster : monstersToSave) {
if (!existingMonsterIds.contains(monster.id)) {
m_db.collectionDAO().addMonsterToCollection(new CollectionMonster(targetCollectionId, monster.id, ordinal++)).blockingAwait();
existingMonsterIds.add(monster.id);
}
}
}
}
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
private static class Helpers {
static boolean monsterMatchesSearch(Monster monster, String searchText) {
if (StringHelper.isNullOrEmpty(searchText)) {

View File

@@ -0,0 +1,34 @@
package com.majinnaibu.monstercards.exporters;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.majinnaibu.monstercards.models.BinderExport;
import com.majinnaibu.monstercards.models.Monster;
import java.util.Collections;
import java.util.List;
public class BinderExporter {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@NonNull
public String exportBinder(String collectionName, List<Monster> monsters) {
BinderExport export = new BinderExport();
export.schemaVersion = 1;
if (monsters != null) {
for (Monster monster : monsters) {
monster.schemaVersion = 1;
}
}
BinderExport.CollectionExport col = new BinderExport.CollectionExport(
collectionName != null ? collectionName : "",
monsters
);
export.collections = Collections.singletonList(col);
return GSON.toJson(export);
}
}

View File

@@ -0,0 +1,17 @@
package com.majinnaibu.monstercards.exporters;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.majinnaibu.monstercards.models.Monster;
public class MonsterCardExporter {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@NonNull
public String exportCard(@NonNull Monster monster) {
monster.schemaVersion = 1;
return GSON.toJson(monster);
}
}

View File

@@ -0,0 +1,37 @@
package com.majinnaibu.monstercards.importers;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.majinnaibu.monstercards.models.BinderExport;
public class BinderImporter implements EntityImporter<BinderExport> {
private static final Gson GSON = new Gson();
@Override
public boolean canImport(@NonNull String input) {
try {
JsonElement el = JsonParser.parseString(input);
if (el.isJsonObject()) {
JsonObject obj = el.getAsJsonObject();
return obj.has("collections") && obj.get("collections").isJsonArray();
}
} catch (Exception ignored) {
}
return false;
}
@NonNull
@Override
public BinderExport parse(@NonNull String input) throws Exception {
BinderExport binder = GSON.fromJson(input, BinderExport.class);
if (binder == null || binder.collections == null) {
throw new IllegalArgumentException("Failed to deserialize Binder JSON");
}
return binder;
}
}

View File

@@ -0,0 +1,30 @@
package com.majinnaibu.monstercards.models;
import com.google.gson.annotations.SerializedName;
import java.util.ArrayList;
import java.util.List;
public class BinderExport {
@SerializedName("schemaVersion")
public int schemaVersion = 1;
@SerializedName("collections")
public List<CollectionExport> collections = new ArrayList<>();
public static class CollectionExport {
@SerializedName("name")
public String name;
@SerializedName("cards")
public List<Monster> cards = new ArrayList<>();
public CollectionExport() {
}
public CollectionExport(String name, List<Monster> cards) {
this.name = name != null ? name : "";
this.cards = cards != null ? cards : new ArrayList<>();
}
}
}

View File

@@ -6,6 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
@@ -29,6 +30,9 @@ import java.util.UUID;
@SuppressWarnings("unused")
public class Monster {
@Ignore
public int schemaVersion = 1;
@PrimaryKey
@NonNull
public UUID id;

View File

@@ -187,10 +187,28 @@ public class CollectionDetailFragment extends MCFragment {
if (item.getItemId() == R.id.menu_action_add_collection_to_dashboard) {
addCollectionToDashboard();
return true;
} else if (item.getItemId() == R.id.menu_action_export_collection) {
exportCollection();
return true;
}
return super.onOptionsItemSelected(item);
}
private void exportCollection() {
Collection collection = mViewModel.getCollection().getValue();
List<Monster> monsters = mViewModel.getMonsters().getValue();
if (collection != null) {
String json = new com.majinnaibu.monstercards.exporters.BinderExporter().exportBinder(
collection.name,
monsters != null ? monsters : new java.util.ArrayList<>()
);
String collectionName = (collection.name != null && !collection.name.trim().isEmpty())
? collection.name.trim()
: "collection";
exportToFile(collectionName + ".binder", json);
}
}
private void addCollectionToDashboard() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.addCollectionToDashboard(mCollectionId)

View File

@@ -274,10 +274,25 @@ public class DashboardFragment extends MCFragment {
} else if (item.getItemId() == R.id.menu_action_clear_dashboard) {
clearDashboard();
return true;
} else if (item.getItemId() == R.id.menu_action_export_dashboard) {
exportDashboard();
return true;
}
return super.onOptionsItemSelected(item);
}
private void exportDashboard() {
mDisposables.add(getMonsterRepository().getDashboardMonsters()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> {
String json = new com.majinnaibu.monstercards.exporters.BinderExporter().exportBinder("", monsters);
String fileName = getString(R.string.default_filename_dashboard) + ".binder";
exportToFile(fileName, json);
}, Logger::logError));
}
@Override
public void onDestroyView() {
super.onDestroyView();

View File

@@ -70,10 +70,25 @@ public class LibraryFragment extends MCFragment {
if (item.getItemId() == R.id.menu_action_import_from_url) {
showImportUrlDialog();
return true;
} else if (item.getItemId() == R.id.menu_action_export_library) {
exportLibrary();
return true;
}
return super.onOptionsItemSelected(item);
}
private void exportLibrary() {
getMonsterRepository().getMonsters()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> {
String json = new com.majinnaibu.monstercards.exporters.BinderExporter().exportBinder("", monsters);
String fileName = getString(R.string.default_filename_library) + ".binder";
exportToFile(fileName, json);
}, Logger::logError);
}
private void showImportUrlDialog() {
Context context = requireContext();
AlertDialog.Builder builder = new AlertDialog.Builder(context);

View File

@@ -195,10 +195,25 @@ public class MonsterDetailFragment extends MCFragment {
} else if (item.getItemId() == R.id.menu_action_share_monster) {
shareCurrentMonster();
return true;
} else if (item.getItemId() == R.id.menu_action_export_card) {
exportCurrentMonsterCard();
return true;
}
return super.onOptionsItemSelected(item);
}
private void exportCurrentMonsterCard() {
Monster monster = mViewModel.getMonster();
if (monster == null) {
return;
}
String jsonCard = new com.majinnaibu.monstercards.exporters.MonsterCardExporter().exportCard(monster);
String safeName = (monster.name != null && !monster.name.trim().isEmpty())
? monster.name.trim()
: "monster";
exportToFile(safeName + ".card", jsonCard);
}
private void shareCurrentMonster() {
Monster monster = mViewModel.getMonster();
if (monster == null) {

View File

@@ -1,15 +1,53 @@
package com.majinnaibu.monstercards.ui.shared;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.DocumentsContract;
import android.provider.OpenableColumns;
import android.view.View;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.MonsterCardsApplication;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.utils.Logger;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class MCFragment extends Fragment {
private String mPendingExportContent;
private ActivityResultLauncher<Intent> mCreateDocumentLauncher;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mCreateDocumentLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
if (uri != null && mPendingExportContent != null) {
writeContentToUri(uri, mPendingExportContent);
}
}
mPendingExportContent = null;
});
}
public MonsterCardsApplication getApplication() {
return (MonsterCardsApplication) requireActivity().getApplication();
}
@@ -32,4 +70,76 @@ public class MCFragment extends Fragment {
}
}
}
protected void exportToFile(@NonNull String defaultFileName, @NonNull String content) {
mPendingExportContent = content;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_TITLE, defaultFileName);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Uri downloadsUri = Uri.parse("content://com.android.externalstorage.documents/document/primary%3ADownload");
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, downloadsUri);
}
try {
mCreateDocumentLauncher.launch(intent);
} catch (Exception e) {
Logger.logError("Failed to launch document creation picker", e);
View view = getView();
if (view != null) {
Snackbar.make(view, R.string.snackbar_export_failed, Snackbar.LENGTH_LONG).show();
}
}
}
private void writeContentToUri(@NonNull Uri uri, @NonNull String content) {
Context context = getContext();
if (context == null) return;
try (OutputStream out = context.getContentResolver().openOutputStream(uri)) {
if (out != null) {
out.write(content.getBytes(StandardCharsets.UTF_8));
out.flush();
View view = getView();
if (view != null) {
String fileName = getFileNameFromUri(context, uri);
String message = getString(R.string.snackbar_export_success, fileName);
Snackbar.make(view, message, Snackbar.LENGTH_LONG).show();
}
}
} catch (Exception e) {
Logger.logError("Failed to write export content to URI", e);
View view = getView();
if (view != null) {
Snackbar.make(view, R.string.snackbar_export_failed, Snackbar.LENGTH_LONG).show();
}
}
}
private String getFileNameFromUri(@NonNull Context context, @NonNull Uri uri) {
String displayName = null;
if ("content".equals(uri.getScheme())) {
try (Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (nameIndex != -1) {
displayName = cursor.getString(nameIndex);
}
}
} catch (Exception e) {
Logger.logError("Error querying display name from content URI", e);
}
}
return displayName != null ? displayName : defaultFileNameFromUri(uri);
}
private String defaultFileNameFromUri(@NonNull Uri uri) {
String path = uri.getPath();
if (path != null) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
return path.substring(lastSlash + 1);
}
}
return uri.toString();
}
}

View File

@@ -6,4 +6,9 @@
android:id="@+id/menu_action_add_collection_to_dashboard"
android:title="@string/action_add_collection_to_dashboard"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_export_collection"
android:title="@string/action_export_collection"
app:showAsAction="never" />
</menu>

View File

@@ -16,4 +16,9 @@
android:id="@+id/menu_action_clear_dashboard"
android:title="@string/action_clear_dashboard"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_export_dashboard"
android:title="@string/action_export_dashboard"
app:showAsAction="never" />
</menu>

View File

@@ -6,4 +6,9 @@
android:id="@+id/menu_action_import_from_url"
android:title="@string/action_import_from_url"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_export_library"
android:title="@string/action_export_library"
app:showAsAction="never" />
</menu>

View File

@@ -23,4 +23,9 @@
android:id="@+id/menu_action_add_to_dashboard"
android:title="@string/action_add_to_dashboard"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_export_card"
android:title="@string/action_export_card"
app:showAsAction="never" />
</menu>

View File

@@ -171,4 +171,13 @@
<string name="failed_to_import_url">Failed to import entity. Please check the URL, ID, or JSON format.</string>
<string name="action_share_monster">Share / Export Monster (.card)</string>
<string name="failed_to_share_monster">Failed to share monster</string>
<string name="action_export_card">Export Card</string>
<string name="action_export_collection">Export Collection</string>
<string name="action_export_library">Export Library</string>
<string name="action_export_dashboard">Export Dashboard</string>
<string name="snackbar_export_success">Exported to %1$s</string>
<string name="snackbar_export_failed">Failed to export file</string>
<string name="snackbar_import_binder_success">Imported binder successfully</string>
<string name="default_filename_library">MonsterCards Library</string>
<string name="default_filename_dashboard">MonsterCards Dashboard</string>
</resources>