From 338a7e4c0e5d35ee35f79b4565f1951951fe6ed9 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Mon, 21 Sep 2026 12:08:25 -0700 Subject: [PATCH] Adds option to import from Open5e.com --- Android/app/build.gradle | 1 + .../majinnaibu/monstercards/MainActivity.java | 66 ++++++++++- .../importers/Open5eApiWrapper.java | 85 +++++++++++++++ .../importers/Open5eImporter.java | 103 ++++++++++++++---- .../monstercards/ui/shared/MCFragment.java | 7 ++ .../monstercards/utils/SnackbarHelper.java | 8 ++ .../app/src/main/res/layout/dialog_import.xml | 7 ++ Android/app/src/main/res/values/strings.xml | 6 + 8 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eApiWrapper.java diff --git a/Android/app/build.gradle b/Android/app/build.gradle index 93f71c7..9b545af 100644 --- a/Android/app/build.gradle +++ b/Android/app/build.gradle @@ -19,6 +19,7 @@ android { defaultConfig { applicationId "com.majinnaibu.monstercards" minSdkVersion 24 + //noinspection OldTargetApi targetSdk 36 versionCode 1 versionName "1.0" diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/MainActivity.java b/Android/app/src/main/java/com/majinnaibu/monstercards/MainActivity.java index fc0f333..f64b293 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/MainActivity.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/MainActivity.java @@ -21,16 +21,20 @@ import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.NavigationUI; +import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.snackbar.Snackbar; 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.importers.Open5eApiWrapper; import com.majinnaibu.monstercards.init.AppCenterInitializer; import com.majinnaibu.monstercards.models.BinderExport; import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.utils.Logger; +import com.majinnaibu.monstercards.utils.SnackbarHelper; import com.majinnaibu.monstercards.utils.ToastHelper; import java.io.BufferedReader; @@ -44,10 +48,13 @@ import java.util.Objects; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public class MainActivity extends AppCompatActivity { + private Disposable mOpen5eImportDisposable; + @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == android.R.id.home) { @@ -67,7 +74,7 @@ public class MainActivity extends AppCompatActivity { AppCenterInitializer.init(getApplication()); setContentView(R.layout.activity_main); - com.google.android.material.appbar.MaterialToolbar toolbar = findViewById(R.id.toolbar); + MaterialToolbar toolbar = findViewById(R.id.toolbar); if (toolbar != null) { setSupportActionBar(toolbar); } @@ -202,6 +209,55 @@ public class MainActivity extends AppCompatActivity { } } + public void importAllFromOpen5e() { + if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) { + ToastHelper.showShort(this, "Open5e import already running"); + return; + } + + View rootView = findViewById(android.R.id.content); + Snackbar snackbar = SnackbarHelper.makeIndefinite(rootView, R.string.snackbar_importing_open5e); + snackbar.setAction(R.string.action_cancel, v -> { + if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) { + mOpen5eImportDisposable.dispose(); + SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_cancelled); + } + }); + 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; + } + ((MonsterCardsApplication) getApplication()).getMonsterRepository() + .saveMonster(monster) + .blockingAwait(); + totalImported++; + } + 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 importMultipleFilesFromUris(@NonNull List uris) { if (uris.isEmpty()) return; if (uris.size() == 1) { @@ -370,4 +426,12 @@ public class MainActivity extends AppCompatActivity { } return builder.toString(); } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) { + mOpen5eImportDisposable.dispose(); + } + } } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eApiWrapper.java b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eApiWrapper.java new file mode 100644 index 0000000..59f5a96 --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eApiWrapper.java @@ -0,0 +1,85 @@ +package com.majinnaibu.monstercards.importers; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.majinnaibu.monstercards.models.Monster; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import com.majinnaibu.monstercards.utils.Logger; + +public class Open5eApiWrapper { + private static final String BASE_URL = "https://api.open5e.com/v2/creatures/"; + + public static class Open5ePageResult { + public List monsters = new ArrayList<>(); + @Nullable public String nextUrl; + } + + @NonNull + public static Open5ePageResult fetchPage(@Nullable String urlStr) throws Exception { + if (urlStr == null || urlStr.isEmpty()) { + urlStr = BASE_URL; + } + + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile)"); + conn.setRequestProperty("Accept", "application/json"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(15000); + + int code = conn.getResponseCode(); + if (code != 200) { + throw new IllegalStateException("Open5e API returned HTTP " + code); + } + + StringBuilder sb = new StringBuilder(); + try (InputStream in = conn.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + + String jsonPayload = sb.toString(); + JsonObject root = JsonParser.parseString(jsonPayload).getAsJsonObject(); + + Open5ePageResult result = new Open5ePageResult(); + if (root.has("next") && !root.get("next").isJsonNull()) { + result.nextUrl = root.get("next").getAsString(); + } + + if (root.has("results") && root.get("results").isJsonArray()) { + JsonArray results = root.getAsJsonArray("results"); + Open5eImporter importer = new Open5eImporter(); + for (int i = 0; i < results.size(); i++) { + JsonElement el = results.get(i); + if (el.isJsonObject()) { + try { + Monster monster = importer.parse(el.toString()); + result.monsters.add(monster); + } catch (Exception e) { + Logger.logError("Failed to parse Open5e monster", e); + } + } + } + } + + return result; + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eImporter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eImporter.java index a618596..0fa5d28 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eImporter.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/Open5eImporter.java @@ -82,7 +82,16 @@ public class Open5eImporter implements EntityImporter { parseArmorClass(props, monster); parseHitPoints(props, monster); - monster.walkSpeed = getInt(props, "speed", 30); + if (props.has("speed") && props.get("speed").isJsonObject()) { + JsonObject speedObj = props.getAsJsonObject("speed"); + monster.walkSpeed = getInt(speedObj, "walk", 30); + monster.flySpeed = getInt(speedObj, "fly", 0); + monster.swimSpeed = getInt(speedObj, "swim", 0); + monster.burrowSpeed = getInt(speedObj, "burrow", 0); + monster.climbSpeed = getInt(speedObj, "climb", 0); + } else { + monster.walkSpeed = getInt(props, "speed", 30); + } if (props.has("speed_desc")) { String speedDesc = getString(props, "speed_desc"); if (!speedDesc.isEmpty()) { @@ -159,6 +168,10 @@ public class Open5eImporter implements EntityImporter { crStr = props.get("challenge_rating").getAsString(); } if (!crStr.isEmpty()) { + crStr = crStr.replace(".0", ""); + if (crStr.equals("0.125")) crStr = "1/8"; + else if (crStr.equals("0.25")) crStr = "1/4"; + else if (crStr.equals("0.5")) crStr = "1/2"; monster.challengeRating = ChallengeRating.valueOfString(crStr); } } @@ -214,7 +227,12 @@ public class Open5eImporter implements EntityImporter { private void parseSavingThrows(JsonObject props, Monster monster) { if (!props.has("saving_throws")) return; JsonElement savesEl = props.get("saving_throws"); - if (savesEl.isJsonArray()) { + if (savesEl.isJsonObject()) { + JsonObject savesObj = savesEl.getAsJsonObject(); + for (String key : savesObj.keySet()) { + applySaveProficiency(monster, key.toLowerCase(Locale.ROOT)); + } + } else if (savesEl.isJsonArray()) { JsonArray arr = savesEl.getAsJsonArray(); for (int i = 0; i < arr.size(); i++) { String saveStr = arr.get(i).getAsString().toLowerCase(Locale.ROOT); @@ -233,16 +251,23 @@ public class Open5eImporter implements EntityImporter { } private void parseSkills(JsonObject props, Monster monster) { - if (!props.has("skills")) return; - JsonElement skillsEl = props.get("skills"); - if (skillsEl.isJsonArray()) { - JsonArray arr = skillsEl.getAsJsonArray(); - for (int i = 0; i < arr.size(); i++) { - String skillStr = arr.get(i).getAsString(); - String skillName = skillStr.replaceAll("[+-]\\d+", "").trim(); - if (!skillName.isEmpty()) { - AbilityScore stat = getAbilityForSkill(skillName); - monster.skills.add(new Skill(capitalize(skillName), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT)); + if (props.has("skill_bonuses") && props.get("skill_bonuses").isJsonObject()) { + JsonObject skillsObj = props.getAsJsonObject("skill_bonuses"); + for (String key : skillsObj.keySet()) { + AbilityScore stat = getAbilityForSkill(key); + monster.skills.add(new Skill(capitalize(key.replace("_", " ")), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT)); + } + } else if (props.has("skills")) { + JsonElement skillsEl = props.get("skills"); + if (skillsEl.isJsonArray()) { + JsonArray arr = skillsEl.getAsJsonArray(); + for (int i = 0; i < arr.size(); i++) { + String skillStr = arr.get(i).getAsString(); + String skillName = skillStr.replaceAll("[+-]\\d+", "").trim(); + if (!skillName.isEmpty()) { + AbilityScore stat = getAbilityForSkill(skillName); + monster.skills.add(new Skill(capitalize(skillName), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT)); + } } } } @@ -274,7 +299,26 @@ public class Open5eImporter implements EntityImporter { private void parseLanguages(JsonObject props, Monster monster) { if (props.has("languages")) { JsonElement langEl = props.get("languages"); - if (langEl.isJsonArray()) { + if (langEl.isJsonObject()) { + if (langEl.getAsJsonObject().has("data") && langEl.getAsJsonObject().get("data").isJsonArray()) { + JsonArray arr = langEl.getAsJsonObject().getAsJsonArray("data"); + for (int i = 0; i < arr.size(); i++) { + if (arr.get(i).isJsonObject()) { + String langName = getString(arr.get(i).getAsJsonObject(), "name"); + if (!langName.isEmpty()) { + monster.languages.add(new Language(langName, true)); + } + } + } + } else if (langEl.getAsJsonObject().has("as_string")) { + String langStr = langEl.getAsJsonObject().get("as_string").getAsString(); + for (String l : langStr.split(",")) { + if (!l.trim().isEmpty()) { + monster.languages.add(new Language(l.trim(), true)); + } + } + } + } else if (langEl.isJsonArray()) { JsonArray arr = langEl.getAsJsonArray(); for (int i = 0; i < arr.size(); i++) { String langName = arr.get(i).getAsString(); @@ -294,10 +338,18 @@ public class Open5eImporter implements EntityImporter { } private void parseDamageAndConditions(JsonObject props, Monster monster) { - parseStringSet(props, "damage_immunities", monster.damageImmunities); - parseStringSet(props, "damage_resistances", monster.damageResistances); - parseStringSet(props, "damage_vulnerabilities", monster.damageVulnerabilities); - parseStringSet(props, "condition_immunities", monster.conditionImmunities); + if (props.has("resistances_and_immunities") && props.get("resistances_and_immunities").isJsonObject()) { + JsonObject resObj = props.getAsJsonObject("resistances_and_immunities"); + parseStringSet(resObj, "damage_immunities", monster.damageImmunities); + parseStringSet(resObj, "damage_resistances", monster.damageResistances); + parseStringSet(resObj, "damage_vulnerabilities", monster.damageVulnerabilities); + parseStringSet(resObj, "condition_immunities", monster.conditionImmunities); + } else { + parseStringSet(props, "damage_immunities", monster.damageImmunities); + parseStringSet(props, "damage_resistances", monster.damageResistances); + parseStringSet(props, "damage_vulnerabilities", monster.damageVulnerabilities); + parseStringSet(props, "condition_immunities", monster.conditionImmunities); + } } private void parseStringSet(JsonObject props, String key, java.util.Set set) { @@ -306,8 +358,14 @@ public class Open5eImporter implements EntityImporter { if (el.isJsonArray()) { JsonArray arr = el.getAsJsonArray(); for (int i = 0; i < arr.size(); i++) { - String val = arr.get(i).getAsString(); - if (!val.isEmpty()) set.add(capitalize(val)); + JsonElement item = arr.get(i); + if (item.isJsonObject() && item.getAsJsonObject().has("name")) { + String val = item.getAsJsonObject().get("name").getAsString(); + if (!val.isEmpty()) set.add(capitalize(val)); + } else if (item.isJsonPrimitive()) { + String val = item.getAsString(); + if (!val.isEmpty()) set.add(capitalize(val)); + } } } else if (el.isJsonPrimitive()) { for (String val : el.getAsString().split(",")) { @@ -357,7 +415,12 @@ public class Open5eImporter implements EntityImporter { private static String getString(JsonObject obj, String key, String defaultVal) { if (obj.has(key) && !obj.get(key).isJsonNull()) { - return obj.get(key).getAsString(); + JsonElement el = obj.get(key); + if (el.isJsonPrimitive()) { + return el.getAsString(); + } else if (el.isJsonObject() && el.getAsJsonObject().has("name")) { + return el.getAsJsonObject().get("name").getAsString(); + } } return defaultVal; } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/shared/MCFragment.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/shared/MCFragment.java index 2f5634d..003d014 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/shared/MCFragment.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/shared/MCFragment.java @@ -155,6 +155,13 @@ public class MCFragment extends Fragment { dialog.dismiss(); }); + view.findViewById(R.id.button_import_open5e).setOnClickListener(v -> { + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).importAllFromOpen5e(); + } + dialog.dismiss(); + }); + dialog.show(); } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/utils/SnackbarHelper.java b/Android/app/src/main/java/com/majinnaibu/monstercards/utils/SnackbarHelper.java index 592b5e2..a0fd953 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/utils/SnackbarHelper.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/utils/SnackbarHelper.java @@ -31,6 +31,14 @@ public class SnackbarHelper { return make(view, message, DEFAULT_LONG_DURATION); } + public static Snackbar makeIndefinite(@NonNull View view, @StringRes int resId) { + return Snackbar.make(view, resId, Snackbar.LENGTH_INDEFINITE); + } + + public static Snackbar makeIndefinite(@NonNull View view, @NonNull CharSequence message) { + return Snackbar.make(view, message, Snackbar.LENGTH_INDEFINITE); + } + public static Snackbar make(@NonNull View view, @NonNull CharSequence message, int durationMs) { return Snackbar.make(view, message, durationMs); } diff --git a/Android/app/src/main/res/layout/dialog_import.xml b/Android/app/src/main/res/layout/dialog_import.xml index d1de088..ae2a9e6 100644 --- a/Android/app/src/main/res/layout/dialog_import.xml +++ b/Android/app/src/main/res/layout/dialog_import.xml @@ -40,4 +40,11 @@ android:layout_height="wrap_content" android:text="@string/action_import_monster_from_file" /> +