diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml
index c389764..c557bdb 100644
--- a/Android/app/src/main/AndroidManifest.xml
+++ b/Android/app/src/main/AndroidManifest.xml
@@ -58,6 +58,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 14f2f94..90c2cf9 100644
--- a/Android/app/src/main/java/com/majinnaibu/monstercards/MainActivity.java
+++ b/Android/app/src/main/java/com/majinnaibu/monstercards/MainActivity.java
@@ -16,8 +16,13 @@ import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;
+import android.widget.Toast;
+
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.DnDBeyondImporter;
import com.majinnaibu.monstercards.init.AppCenterInitializer;
import com.majinnaibu.monstercards.utils.Logger;
@@ -28,6 +33,10 @@ import java.io.InputStreamReader;
import java.util.Locale;
import java.util.Objects;
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
public class MainActivity extends AppCompatActivity {
@Override
@@ -66,15 +75,57 @@ public class MainActivity extends AppCompatActivity {
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
+ if (intent == null) {
+ return;
+ }
+ String action = intent.getAction();
+ Bundle extras = intent.getExtras();
+
+ if ("android.intent.action.SEND".equals(action) && extras != null) {
+ CharSequence sharedText = extras.getCharSequence(Intent.EXTRA_TEXT);
+ if (sharedText != null) {
+ String textStr = sharedText.toString().trim();
+ DnDBeyondImporter dndImporter = new DnDBeyondImporter();
+ if (dndImporter.canImport(textStr)) {
+ importMonsterFromInputAndNavigate(textStr);
+ return;
+ }
+ }
+ } else if ("android.intent.action.VIEW".equals(action)) {
+ Uri dataUri = intent.getData();
+ if (dataUri != null) {
+ String dataStr = dataUri.toString().trim();
+ DnDBeyondImporter dndImporter = new DnDBeyondImporter();
+ if (dndImporter.canImport(dataStr)) {
+ importMonsterFromInputAndNavigate(dataStr);
+ return;
+ }
+ }
+ }
+
String json = readMonsterJSONFromIntent(intent);
if (!StringHelper.isNullOrEmpty(json)) {
- NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
- NavController navController = navHostFragment.getNavController();
- NavDirections action = MobileNavigationDirections.actionGlobalMonsterImportFragment(json);
- navController.navigate(action);
+ importMonsterFromInputAndNavigate(json);
}
}
+ public void importMonsterFromInputAndNavigate(@NonNull String input) {
+ Toast.makeText(this, R.string.toast_importing_url, Toast.LENGTH_SHORT).show();
+ Single.fromCallable(() -> MonsterImportHelper.fromJSON(input))
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(monster -> {
+ String serializedJson = new Gson().toJson(monster);
+ NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
+ NavController navController = navHostFragment.getNavController();
+ NavDirections navAction = MobileNavigationDirections.actionGlobalMonsterImportFragment(serializedJson);
+ navController.navigate(navAction);
+ }, throwable -> {
+ Logger.logError("Failed to import monster from input", throwable);
+ Toast.makeText(this, R.string.failed_to_import_url, Toast.LENGTH_LONG).show();
+ });
+ }
+
@Nullable
private String readMonsterJSONFromIntent(@NonNull Intent intent) {
String action = intent.getAction();
diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/helpers/MonsterImportHelper.java b/Android/app/src/main/java/com/majinnaibu/monstercards/helpers/MonsterImportHelper.java
index b54bfa9..88968f9 100644
--- a/Android/app/src/main/java/com/majinnaibu/monstercards/helpers/MonsterImportHelper.java
+++ b/Android/app/src/main/java/com/majinnaibu/monstercards/helpers/MonsterImportHelper.java
@@ -2,7 +2,9 @@ package com.majinnaibu.monstercards.helpers;
import androidx.annotation.NonNull;
+import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.importers.EntityImporter;
+import com.majinnaibu.monstercards.importers.MonsterJsonImporter;
import com.majinnaibu.monstercards.importers.TetraCubeMonsterImporter;
import com.majinnaibu.monstercards.models.Monster;
@@ -13,7 +15,9 @@ public class MonsterImportHelper {
private static final List> IMPORTERS = new ArrayList<>();
static {
+ IMPORTERS.add(new MonsterJsonImporter());
IMPORTERS.add(new TetraCubeMonsterImporter());
+ IMPORTERS.add(new DnDBeyondImporter());
}
@NonNull
diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/DnDBeyondImporter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/DnDBeyondImporter.java
new file mode 100644
index 0000000..3e75382
--- /dev/null
+++ b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/DnDBeyondImporter.java
@@ -0,0 +1,418 @@
+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.data.enums.AbilityScore;
+import com.majinnaibu.monstercards.data.enums.AdvantageType;
+import com.majinnaibu.monstercards.data.enums.ProficiencyType;
+import com.majinnaibu.monstercards.models.Language;
+import com.majinnaibu.monstercards.models.Monster;
+import com.majinnaibu.monstercards.models.Skill;
+import com.majinnaibu.monstercards.models.Trait;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.net.ssl.HttpsURLConnection;
+
+public class DnDBeyondImporter implements EntityImporter {
+
+ private static final Pattern DNDBEYOND_URL_PATTERN = Pattern.compile("https?://(?:www\\.)?dndbeyond\\.com/characters/(\\d+)(?:/([a-zA-Z0-9]+))?", Pattern.CASE_INSENSITIVE);
+ private static final Pattern CHARACTER_ID_PATTERN = Pattern.compile("^\\d+$");
+
+ @Override
+ public boolean canImport(@NonNull String input) {
+ String trimmed = input.trim();
+ if (trimmed.isEmpty()) {
+ return false;
+ }
+ if (DNDBEYOND_URL_PATTERN.matcher(trimmed).find() || CHARACTER_ID_PATTERN.matcher(trimmed).matches()) {
+ return true;
+ }
+ try {
+ JsonElement element = JsonParser.parseString(trimmed);
+ if (element.isJsonObject()) {
+ JsonObject root = element.getAsJsonObject();
+ if (root.has("data") && root.getAsJsonObject("data").has("baseHitPoints")) {
+ return true;
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Monster parse(@NonNull String input) throws Exception {
+ String trimmed = input.trim();
+ JsonObject characterData;
+ String sourceUrl = "";
+
+ if (trimmed.startsWith("{")) {
+ JsonObject root = JsonParser.parseString(trimmed).getAsJsonObject();
+ characterData = root.has("data") ? root.getAsJsonObject("data") : root;
+ } else {
+ String characterId = extractCharacterId(trimmed);
+ if (characterId == null) {
+ throw new IllegalArgumentException("Could not extract D&D Beyond character ID from input: " + input);
+ }
+ sourceUrl = trimmed.startsWith("http") ? trimmed : "https://www.dndbeyond.com/characters/" + characterId;
+ String jsonPayload = fetchCharacterJson(characterId);
+ JsonObject root = JsonParser.parseString(jsonPayload).getAsJsonObject();
+ if (!root.has("data") || root.get("data").isJsonNull()) {
+ throw new IllegalArgumentException("D&D Beyond response did not contain character data.");
+ }
+ characterData = root.getAsJsonObject("data");
+ }
+
+ return buildMonsterFromCharacterData(characterData, sourceUrl);
+ }
+
+ @Nullable
+ public static String extractCharacterId(@NonNull String input) {
+ String trimmed = input.trim();
+ Matcher urlMatcher = DNDBEYOND_URL_PATTERN.matcher(trimmed);
+ if (urlMatcher.find()) {
+ return urlMatcher.group(1);
+ }
+ if (CHARACTER_ID_PATTERN.matcher(trimmed).matches()) {
+ return trimmed;
+ }
+ return null;
+ }
+
+ @NonNull
+ private String fetchCharacterJson(@NonNull String characterId) throws Exception {
+ String serviceUrl = "https://character-service.dndbeyond.com/character/v5/character/" + characterId + "?includeCustomItems=true";
+ URL url = new URL(serviceUrl);
+ HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile)");
+ conn.setRequestProperty("Accept", "application/json");
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(10000);
+
+ int code = conn.getResponseCode();
+ if (code != 200) {
+ throw new IllegalStateException("D&D Beyond service 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);
+ }
+ }
+ return sb.toString();
+ }
+
+ @NonNull
+ private Monster buildMonsterFromCharacterData(@NonNull JsonObject data, @NonNull String sourceUrl) {
+ Monster monster = new Monster();
+ monster.sourceUrl = sourceUrl;
+
+ String rawName = getString(data, "name");
+ if (rawName.startsWith("\"") && rawName.endsWith("\"") && rawName.length() > 1) {
+ rawName = rawName.substring(1, rawName.length() - 1);
+ }
+ monster.name = rawName.isEmpty() ? "D&D Beyond Character" : rawName;
+
+ monster.size = "Medium";
+ monster.type = "Humanoid";
+
+ parseClassesAndLevel(data, monster);
+ parseStats(data, monster);
+ parseHitPoints(data, monster);
+ parseSpeedAndSenses(data, monster);
+ parseModifiers(data, monster);
+ parseTraitsAndFeatures(data, monster);
+ parseActionsAndAttacks(data, monster);
+
+ return monster;
+ }
+
+ private void parseClassesAndLevel(@NonNull JsonObject data, @NonNull Monster monster) {
+ StringBuilder classSummary = new StringBuilder();
+ int totalLevel = 0;
+ if (data.has("classes") && data.get("classes").isJsonArray()) {
+ JsonArray classes = data.getAsJsonArray("classes");
+ for (int i = 0; i < classes.size(); i++) {
+ JsonObject cls = classes.get(i).getAsJsonObject();
+ int level = getInt(cls, "level", 1);
+ totalLevel += level;
+ String className = "";
+ if (cls.has("definition") && cls.get("definition").isJsonObject()) {
+ className = getString(cls.getAsJsonObject("definition"), "name");
+ }
+ String subclassName = "";
+ if (cls.has("subclassDefinition") && cls.get("subclassDefinition").isJsonObject()) {
+ subclassName = getString(cls.getAsJsonObject("subclassDefinition"), "name");
+ }
+ if (classSummary.length() > 0) {
+ classSummary.append(" / ");
+ }
+ classSummary.append(className);
+ if (!subclassName.isEmpty()) {
+ classSummary.append(" (").append(subclassName).append(")");
+ }
+ classSummary.append(" ").append(level);
+ }
+ }
+ monster.hitDice = totalLevel > 0 ? totalLevel : 1;
+ String raceName = "";
+ if (data.has("race") && data.get("race").isJsonObject()) {
+ JsonObject race = data.getAsJsonObject("race");
+ raceName = getString(race, "fullName");
+ if (raceName.isEmpty()) {
+ raceName = getString(race, "baseRaceName");
+ }
+ }
+ StringBuilder subtypeSb = new StringBuilder();
+ if (totalLevel > 0) {
+ subtypeSb.append("Level ").append(totalLevel);
+ }
+ if (!raceName.isEmpty()) {
+ if (subtypeSb.length() > 0) subtypeSb.append(" ");
+ subtypeSb.append(raceName);
+ }
+ if (classSummary.length() > 0) {
+ if (subtypeSb.length() > 0) subtypeSb.append(" ");
+ subtypeSb.append(classSummary);
+ }
+ monster.subtype = subtypeSb.toString();
+ }
+
+ private void parseStats(@NonNull JsonObject data, @NonNull Monster monster) {
+ if (data.has("stats") && data.get("stats").isJsonArray()) {
+ JsonArray stats = data.getAsJsonArray("stats");
+ for (int i = 0; i < stats.size(); i++) {
+ JsonObject stat = stats.get(i).getAsJsonObject();
+ int id = getInt(stat, "id");
+ int val = getInt(stat, "value", 10);
+ switch (id) {
+ case 1: monster.strengthScore = val; break;
+ case 2: monster.dexterityScore = val; break;
+ case 3: monster.constitutionScore = val; break;
+ case 4: monster.intelligenceScore = val; break;
+ case 5: monster.wisdomScore = val; break;
+ case 6: monster.charismaScore = val; break;
+ }
+ }
+ }
+ }
+
+ private void parseHitPoints(@NonNull JsonObject data, @NonNull Monster monster) {
+ int baseHp = getInt(data, "baseHitPoints");
+ int overrideHp = getInt(data, "overrideHitPoints");
+ int maxHp = overrideHp > 0 ? overrideHp : baseHp;
+ if (maxHp > 0) {
+ monster.hasCustomHP = true;
+ monster.customHPDescription = String.format(Locale.ROOT, "%d (%dd%d)", maxHp, monster.hitDice, 8);
+ }
+ }
+
+ private void parseSpeedAndSenses(@NonNull JsonObject data, @NonNull Monster monster) {
+ if (data.has("race") && data.get("race").isJsonObject()) {
+ JsonObject race = data.getAsJsonObject("race");
+ if (race.has("weightSpeeds") && race.get("weightSpeeds").isJsonObject()) {
+ JsonObject weightSpeeds = race.getAsJsonObject("weightSpeeds");
+ if (weightSpeeds.has("normal") && weightSpeeds.get("normal").isJsonObject()) {
+ JsonObject normal = weightSpeeds.getAsJsonObject("normal");
+ monster.walkSpeed = getInt(normal, "walk", 30);
+ monster.flySpeed = getInt(normal, "fly", 0);
+ monster.burrowSpeed = getInt(normal, "burrow", 0);
+ monster.swimSpeed = getInt(normal, "swim", 0);
+ monster.climbSpeed = getInt(normal, "climb", 0);
+ }
+ }
+ }
+ if (monster.walkSpeed == 0) {
+ monster.walkSpeed = 30;
+ }
+ }
+
+ private void parseModifiers(@NonNull JsonObject data, @NonNull Monster monster) {
+ if (!data.has("modifiers") || !data.get("modifiers").isJsonObject()) {
+ return;
+ }
+ JsonObject modifiersObj = data.getAsJsonObject("modifiers");
+ String[] categories = new String[]{"race", "class", "background", "item", "feat"};
+
+ for (String cat : categories) {
+ if (modifiersObj.has(cat) && modifiersObj.get(cat).isJsonArray()) {
+ JsonArray mods = modifiersObj.getAsJsonArray(cat);
+ for (int i = 0; i < mods.size(); i++) {
+ JsonObject mod = mods.get(i).getAsJsonObject();
+ String type = getString(mod, "type");
+ String subType = getString(mod, "subType");
+ int fixedVal = getInt(mod, "fixedValue");
+
+ if ("set-base".equals(type) && "darkvision".equals(subType)) {
+ int dist = fixedVal > 0 ? fixedVal : 60;
+ monster.senses.add("darkvision " + dist + " ft.");
+ } else if ("language".equals(type)) {
+ String langName = capitalize(subType.replace("-", " "));
+ monster.languages.add(new Language(langName, true));
+ } else if ("proficiency".equals(type)) {
+ if (subType.endsWith("-saving-throws")) {
+ String stat = subType.replace("-saving-throws", "");
+ applySavingThrowProficiency(monster, stat);
+ } else {
+ applySkillProficiency(monster, subType, ProficiencyType.PROFICIENT);
+ }
+ } else if ("expertise".equals(type)) {
+ applySkillProficiency(monster, subType, ProficiencyType.EXPERTISE);
+ } else if ("immunity".equals(type)) {
+ monster.damageImmunities.add(capitalize(subType.replace("-", " ")));
+ } else if ("resistance".equals(type)) {
+ monster.damageResistances.add(capitalize(subType.replace("-", " ")));
+ } else if ("vulnerability".equals(type)) {
+ monster.damageVulnerabilities.add(capitalize(subType.replace("-", " ")));
+ }
+ }
+ }
+ }
+ }
+
+ private void applySavingThrowProficiency(@NonNull Monster monster, String stat) {
+ if ("strength".equals(stat) || "str".equals(stat)) monster.strengthSavingThrowProficiency = ProficiencyType.PROFICIENT;
+ else if ("dexterity".equals(stat) || "dex".equals(stat)) monster.dexteritySavingThrowProficiency = ProficiencyType.PROFICIENT;
+ else if ("constitution".equals(stat) || "con".equals(stat)) monster.constitutionSavingThrowProficiency = ProficiencyType.PROFICIENT;
+ else if ("intelligence".equals(stat) || "int".equals(stat)) monster.intelligenceSavingThrowProficiency = ProficiencyType.PROFICIENT;
+ else if ("wisdom".equals(stat) || "wis".equals(stat)) monster.wisdomSavingThrowProficiency = ProficiencyType.PROFICIENT;
+ else if ("charisma".equals(stat) || "cha".equals(stat)) monster.charismaSavingThrowProficiency = ProficiencyType.PROFICIENT;
+ }
+
+ private void applySkillProficiency(@NonNull Monster monster, String subType, ProficiencyType profType) {
+ String skillName = capitalize(subType.replace("-", " "));
+ AbilityScore abilityScore = AbilityScore.STRENGTH;
+ if ("Acrobatics".equalsIgnoreCase(skillName) || "Sleight Of Hand".equalsIgnoreCase(skillName) || "Stealth".equalsIgnoreCase(skillName)) {
+ abilityScore = AbilityScore.DEXTERITY;
+ } else if ("Arcana".equalsIgnoreCase(skillName) || "History".equalsIgnoreCase(skillName) || "Investigation".equalsIgnoreCase(skillName) || "Nature".equalsIgnoreCase(skillName) || "Religion".equalsIgnoreCase(skillName)) {
+ abilityScore = AbilityScore.INTELLIGENCE;
+ } else if ("Animal Handling".equalsIgnoreCase(skillName) || "Insight".equalsIgnoreCase(skillName) || "Medicine".equalsIgnoreCase(skillName) || "Perception".equalsIgnoreCase(skillName) || "Survival".equalsIgnoreCase(skillName)) {
+ abilityScore = AbilityScore.WISDOM;
+ } else if ("Deception".equalsIgnoreCase(skillName) || "Intimidation".equalsIgnoreCase(skillName) || "Performance".equalsIgnoreCase(skillName) || "Persuasion".equalsIgnoreCase(skillName)) {
+ abilityScore = AbilityScore.CHARISMA;
+ } else if ("Athletics".equalsIgnoreCase(skillName)) {
+ abilityScore = AbilityScore.STRENGTH;
+ }
+ monster.skills.add(new Skill(skillName, abilityScore, AdvantageType.NONE, profType));
+ }
+
+ private void parseTraitsAndFeatures(@NonNull JsonObject data, @NonNull Monster monster) {
+ if (data.has("race") && data.get("race").isJsonObject()) {
+ JsonObject race = data.getAsJsonObject("race");
+ if (race.has("racialTraits") && race.get("racialTraits").isJsonArray()) {
+ JsonArray traits = race.getAsJsonArray("racialTraits");
+ for (int i = 0; i < traits.size(); i++) {
+ JsonObject tr = traits.get(i).getAsJsonObject();
+ if (tr.has("definition") && tr.get("definition").isJsonObject()) {
+ JsonObject def = tr.getAsJsonObject("definition");
+ String name = getString(def, "name");
+ String desc = stripHtml(getString(def, "snippet"));
+ if (desc.isEmpty()) {
+ desc = stripHtml(getString(def, "description"));
+ }
+ if (!name.isEmpty() && !desc.isEmpty()) {
+ monster.abilities.add(new Trait(name, desc));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void parseActionsAndAttacks(@NonNull JsonObject data, @NonNull Monster monster) {
+ if (data.has("actions") && data.get("actions").isJsonObject()) {
+ JsonObject actionsObj = data.getAsJsonObject("actions");
+ String[] categories = new String[]{"class", "race", "background", "item", "feat"};
+ for (String cat : categories) {
+ if (actionsObj.has(cat) && actionsObj.get(cat).isJsonArray()) {
+ JsonArray acts = actionsObj.getAsJsonArray(cat);
+ for (int i = 0; i < acts.size(); i++) {
+ JsonObject act = acts.get(i).getAsJsonObject();
+ String name = getString(act, "name");
+ String desc = stripHtml(getString(act, "snippet"));
+ if (desc.isEmpty()) {
+ desc = stripHtml(getString(act, "description"));
+ }
+ int actType = 1;
+ if (act.has("activation") && act.get("activation").isJsonObject()) {
+ actType = getInt(act.getAsJsonObject("activation"), "activationType", 1);
+ }
+ if (!name.isEmpty()) {
+ Trait trait = new Trait(name, desc);
+ if (actType == 3) {
+ monster.bonusActions.add(trait);
+ } else if (actType == 4) {
+ monster.reactions.add(trait);
+ } else {
+ monster.actions.add(trait);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static String getString(@NonNull JsonObject obj, String key) {
+ return getString(obj, key, "");
+ }
+
+ private static String getString(@NonNull JsonObject obj, String key, String defaultVal) {
+ if (obj.has(key) && !obj.get(key).isJsonNull()) {
+ return obj.get(key).getAsString();
+ }
+ return defaultVal;
+ }
+
+ private static int getInt(@NonNull JsonObject obj, String key) {
+ return getInt(obj, key, 0);
+ }
+
+ private static int getInt(@NonNull JsonObject obj, String key, int defaultVal) {
+ if (obj.has(key) && !obj.get(key).isJsonNull()) {
+ try {
+ return obj.get(key).getAsInt();
+ } catch (Exception ignored) {
+ }
+ }
+ return defaultVal;
+ }
+
+ private static String stripHtml(String html) {
+ if (html == null) return "";
+ return html.replaceAll("<[^>]*>", "").replaceAll(" ", " ").trim();
+ }
+
+ private static String capitalize(String str) {
+ if (str == null || str.isEmpty()) return str;
+ String[] words = str.split("\\s+");
+ StringBuilder sb = new StringBuilder();
+ for (String w : words) {
+ if (!w.isEmpty()) {
+ if (sb.length() > 0) sb.append(" ");
+ sb.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1).toLowerCase(Locale.ROOT));
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/MonsterJsonImporter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/MonsterJsonImporter.java
new file mode 100644
index 0000000..adc00a9
--- /dev/null
+++ b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/MonsterJsonImporter.java
@@ -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.Monster;
+
+public class MonsterJsonImporter implements EntityImporter {
+
+ 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("name") && (obj.has("strengthScore") || obj.has("hitDice") || obj.has("size"));
+ }
+ } catch (Exception ignored) {
+ }
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Monster parse(@NonNull String input) throws Exception {
+ Monster monster = GSON.fromJson(input, Monster.class);
+ if (monster == null) {
+ throw new IllegalArgumentException("Failed to deserialize Monster JSON");
+ }
+ return monster;
+ }
+}
diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/TetraCubeMonsterImporter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/TetraCubeMonsterImporter.java
index f0ee4df..967bee2 100644
--- a/Android/app/src/main/java/com/majinnaibu/monstercards/importers/TetraCubeMonsterImporter.java
+++ b/Android/app/src/main/java/com/majinnaibu/monstercards/importers/TetraCubeMonsterImporter.java
@@ -36,8 +36,8 @@ public class TetraCubeMonsterImporter implements EntityImporter {
return false;
}
JsonObject rootDict = element.getAsJsonObject();
- return rootDict.has("hitDice") || rootDict.has("armorName") || rootDict.has("strPoints")
- || rootDict.has("bonusActions") || rootDict.has("isLegendary") || rootDict.has("isMythic")
+ return rootDict.has("armorName") || rootDict.has("strPoints")
+ || rootDict.has("isLegendary") || rootDict.has("isMythic")
|| rootDict.has("legendariesDescription");
} catch (Exception e) {
return false;
diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/library/LibraryFragment.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/library/LibraryFragment.java
index f6497a2..843a5eb 100644
--- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/library/LibraryFragment.java
+++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/library/LibraryFragment.java
@@ -1,12 +1,19 @@
package com.majinnaibu.monstercards.ui.library;
+import android.content.ClipData;
+import android.content.ClipboardManager;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.EditText;
import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
import androidx.navigation.NavController;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
@@ -17,8 +24,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
+import com.majinnaibu.monstercards.MainActivity;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
+import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.monster.MonsterDetailFragmentDirections;
import com.majinnaibu.monstercards.ui.shared.MCFragment;
@@ -35,6 +44,7 @@ public class LibraryFragment extends MCFragment {
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
+ setHasOptionsMenu(true);
View root = inflater.inflate(R.layout.fragment_library, container, false);
FloatingActionButton fab = root.findViewById(R.id.fab);
@@ -48,6 +58,59 @@ public class LibraryFragment extends MCFragment {
return root;
}
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+ inflater.inflate(R.menu.library_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ if (item.getItemId() == R.id.menu_action_import_from_url) {
+ showImportUrlDialog();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void showImportUrlDialog() {
+ Context context = requireContext();
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.dialog_import_url_title);
+ builder.setMessage(R.string.dialog_import_url_message);
+
+ final EditText input = new EditText(context);
+ input.setHint(R.string.dialog_import_url_hint);
+ input.setSingleLine(true);
+
+ ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null && clipboard.hasPrimaryClip()) {
+ ClipData clip = clipboard.getPrimaryClip();
+ if (clip != null && clip.getItemCount() > 0) {
+ CharSequence clipText = clip.getItemAt(0).getText();
+ if (clipText != null && new DnDBeyondImporter().canImport(clipText.toString())) {
+ input.setText(clipText.toString().trim());
+ input.selectAll();
+ }
+ }
+ }
+
+ builder.setView(input);
+
+ builder.setPositiveButton(R.string.dialog_import, (dialog, which) -> {
+ String urlOrId = input.getText().toString().trim();
+ if (!urlOrId.isEmpty()) {
+ if (getActivity() instanceof MainActivity) {
+ ((MainActivity) getActivity()).importMonsterFromInputAndNavigate(urlOrId);
+ }
+ }
+ });
+
+ builder.setNegativeButton(R.string.dialog_cancel, (dialog, which) -> dialog.cancel());
+
+ builder.show();
+ }
+
private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
Context context = requireContext();
MonsterRepository repository = this.getMonsterRepository();
diff --git a/Android/app/src/main/res/menu/library_menu.xml b/Android/app/src/main/res/menu/library_menu.xml
new file mode 100644
index 0000000..4b0813a
--- /dev/null
+++ b/Android/app/src/main/res/menu/library_menu.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml
index 2c57222..97b1641 100644
--- a/Android/app/src/main/res/values/strings.xml
+++ b/Android/app/src/main/res/values/strings.xml
@@ -162,4 +162,11 @@
Added collection %1$s to Dashboard
Dashboard cleared
No monsters available in library. Create one first!
+ Import from URL...
+ Import from D&D Beyond
+ Enter or paste a D&D Beyond character URL or Character ID:
+ e.g. https://www.dndbeyond.com/characters/49074997/s7sLyX
+ Import
+ Importing character from D&D Beyond...
+ Failed to import character. Please check the URL or character privacy settings.
diff --git a/Android/app/src/test/java/com/majinnaibu/monstercards/importers/DnDBeyondImporterTest.java b/Android/app/src/test/java/com/majinnaibu/monstercards/importers/DnDBeyondImporterTest.java
new file mode 100644
index 0000000..c8d5827
--- /dev/null
+++ b/Android/app/src/test/java/com/majinnaibu/monstercards/importers/DnDBeyondImporterTest.java
@@ -0,0 +1,72 @@
+package com.majinnaibu.monstercards.importers;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
+import com.majinnaibu.monstercards.models.Monster;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.nio.file.Files;
+
+public class DnDBeyondImporterTest {
+
+ private DnDBeyondImporter importer;
+
+ @Before
+ public void setUp() {
+ importer = new DnDBeyondImporter();
+ }
+
+ @Test
+ public void testCanImport_UrlAndNumericIdAndJson() {
+ assertTrue(importer.canImport("https://www.dndbeyond.com/characters/49074997/s7sLyX"));
+ assertTrue(importer.canImport("https://www.dndbeyond.com/characters/49074997"));
+ assertTrue(importer.canImport("49074997"));
+ assertTrue(importer.canImport(" 49074997 "));
+ assertFalse(importer.canImport("invalid url"));
+ assertFalse(importer.canImport(""));
+ }
+
+ @Test
+ public void testExtractCharacterId() {
+ assertEquals("49074997", DnDBeyondImporter.extractCharacterId("https://www.dndbeyond.com/characters/49074997/s7sLyX"));
+ assertEquals("49074997", DnDBeyondImporter.extractCharacterId("https://www.dndbeyond.com/characters/49074997"));
+ assertEquals("49074997", DnDBeyondImporter.extractCharacterId("49074997"));
+ }
+
+ @Test
+ public void testParseSampleCharacterJson() throws Exception {
+ File sampleFile = new File("../docs/dndbeyond-character-sample.json");
+ if (!sampleFile.exists()) {
+ sampleFile = new File("../../docs/dndbeyond-character-sample.json");
+ }
+ assertTrue("Sample character JSON file should exist", sampleFile.exists());
+
+ String json = new String(Files.readAllBytes(sampleFile.toPath()));
+ assertTrue(importer.canImport(json));
+
+ Monster monster = importer.parse(json);
+ assertNotNull(monster);
+ assertEquals("Joy", monster.name);
+ assertEquals(12, monster.strengthScore);
+ assertEquals(15, monster.dexterityScore);
+ assertEquals(14, monster.constitutionScore);
+ assertEquals(10, monster.intelligenceScore);
+ assertEquals(13, monster.wisdomScore);
+ assertEquals(17, monster.charismaScore);
+
+ assertTrue(monster.hasCustomHP);
+ assertEquals("28 (5d8)", monster.customHPDescription);
+ assertEquals(5, monster.hitDice);
+
+ Monster delegatedMonster = MonsterImportHelper.fromJSON(json);
+ assertNotNull(delegatedMonster);
+ assertEquals("Joy", delegatedMonster.name);
+ }
+}