Adds ability to import from a public dndbeyond character.

This commit is contained in:
2026-09-07 20:34:12 -07:00
parent 2af18cbf1d
commit a3d1a139e9
10 changed files with 681 additions and 6 deletions

View File

@@ -58,6 +58,20 @@
<data android:mimeType="application/octet-stream" /> <data android:mimeType="application/octet-stream" />
</intent-filter> </intent-filter>
<!-- Intent filter for viewing dndbeyond.com character URLs directly in Monster Cards -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="www.dndbeyond.com" />
<data android:host="dndbeyond.com" />
<data android:pathPrefix="/characters/" />
</intent-filter>
<nav-graph android:value="@navigation/mobile_navigation" /> <nav-graph android:value="@navigation/mobile_navigation" />
</activity> </activity>
</application> </application>

View File

@@ -16,8 +16,13 @@ import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI; import androidx.navigation.ui.NavigationUI;
import android.widget.Toast;
import com.google.android.material.bottomnavigation.BottomNavigationView; 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.helpers.StringHelper;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.init.AppCenterInitializer; import com.majinnaibu.monstercards.init.AppCenterInitializer;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
@@ -28,6 +33,10 @@ import java.io.InputStreamReader;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; 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 { public class MainActivity extends AppCompatActivity {
@Override @Override
@@ -66,15 +75,57 @@ public class MainActivity extends AppCompatActivity {
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
super.onNewIntent(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); String json = readMonsterJSONFromIntent(intent);
if (!StringHelper.isNullOrEmpty(json)) { if (!StringHelper.isNullOrEmpty(json)) {
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment)); importMonsterFromInputAndNavigate(json);
NavController navController = navHostFragment.getNavController();
NavDirections action = MobileNavigationDirections.actionGlobalMonsterImportFragment(json);
navController.navigate(action);
} }
} }
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 @Nullable
private String readMonsterJSONFromIntent(@NonNull Intent intent) { private String readMonsterJSONFromIntent(@NonNull Intent intent) {
String action = intent.getAction(); String action = intent.getAction();

View File

@@ -2,7 +2,9 @@ package com.majinnaibu.monstercards.helpers;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.importers.EntityImporter; import com.majinnaibu.monstercards.importers.EntityImporter;
import com.majinnaibu.monstercards.importers.MonsterJsonImporter;
import com.majinnaibu.monstercards.importers.TetraCubeMonsterImporter; import com.majinnaibu.monstercards.importers.TetraCubeMonsterImporter;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
@@ -13,7 +15,9 @@ public class MonsterImportHelper {
private static final List<EntityImporter<Monster>> IMPORTERS = new ArrayList<>(); private static final List<EntityImporter<Monster>> IMPORTERS = new ArrayList<>();
static { static {
IMPORTERS.add(new MonsterJsonImporter());
IMPORTERS.add(new TetraCubeMonsterImporter()); IMPORTERS.add(new TetraCubeMonsterImporter());
IMPORTERS.add(new DnDBeyondImporter());
} }
@NonNull @NonNull

View File

@@ -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<Monster> {
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("&nbsp;", " ").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();
}
}

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.Monster;
public class MonsterJsonImporter implements EntityImporter<Monster> {
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;
}
}

View File

@@ -36,8 +36,8 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
return false; return false;
} }
JsonObject rootDict = element.getAsJsonObject(); JsonObject rootDict = element.getAsJsonObject();
return rootDict.has("hitDice") || rootDict.has("armorName") || rootDict.has("strPoints") return rootDict.has("armorName") || rootDict.has("strPoints")
|| rootDict.has("bonusActions") || rootDict.has("isLegendary") || rootDict.has("isMythic") || rootDict.has("isLegendary") || rootDict.has("isMythic")
|| rootDict.has("legendariesDescription"); || rootDict.has("legendariesDescription");
} catch (Exception e) { } catch (Exception e) {
return false; return false;

View File

@@ -1,12 +1,19 @@
package com.majinnaibu.monstercards.ui.library; package com.majinnaibu.monstercards.ui.library;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.EditText;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.NavDirections; import androidx.navigation.NavDirections;
import androidx.navigation.Navigation; 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.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.MainActivity;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.monster.MonsterDetailFragmentDirections; import com.majinnaibu.monstercards.ui.monster.MonsterDetailFragmentDirections;
import com.majinnaibu.monstercards.ui.shared.MCFragment; import com.majinnaibu.monstercards.ui.shared.MCFragment;
@@ -35,6 +44,7 @@ public class LibraryFragment extends MCFragment {
public View onCreateView(@NonNull LayoutInflater inflater, public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) { ViewGroup container, Bundle savedInstanceState) {
setHasOptionsMenu(true);
View root = inflater.inflate(R.layout.fragment_library, container, false); View root = inflater.inflate(R.layout.fragment_library, container, false);
FloatingActionButton fab = root.findViewById(R.id.fab); FloatingActionButton fab = root.findViewById(R.id.fab);
@@ -48,6 +58,59 @@ public class LibraryFragment extends MCFragment {
return root; 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) { private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
Context context = requireContext(); Context context = requireContext();
MonsterRepository repository = this.getMonsterRepository(); MonsterRepository repository = this.getMonsterRepository();

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_action_import_from_url"
android:title="@string/action_import_from_url"
app:showAsAction="never" />
</menu>

View File

@@ -162,4 +162,11 @@
<string name="snackbar_collection_added_to_dashboard">Added collection %1$s to Dashboard</string> <string name="snackbar_collection_added_to_dashboard">Added collection %1$s to Dashboard</string>
<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 from URL...</string>
<string name="dialog_import_url_title">Import from D&amp;D Beyond</string>
<string name="dialog_import_url_message">Enter or paste a D&amp;D Beyond character URL or Character ID:</string>
<string name="dialog_import_url_hint">e.g. https://www.dndbeyond.com/characters/49074997/s7sLyX</string>
<string name="dialog_import">Import</string>
<string name="toast_importing_url">Importing character from D&amp;D Beyond...</string>
<string name="failed_to_import_url">Failed to import character. Please check the URL or character privacy settings.</string>
</resources> </resources>

View File

@@ -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);
}
}