Reference for the JSON file PsychonautWiki Journal v15 writes with Settings →
Export and reads back with Import. This is the format
tools/convert_v11_to_v15.py targets, and the
interchange format between the Android app, the iOS app, and compatible
clients.
Everything below was extracted from the shipped v15.5 Android APK — the
kotlinx.serialization descriptors of the export DTOs (...Serializable classes
under com.isaakhanimann.journal.ui.tabs.settings), the enum classes they
reference, the Json {} configuration, and the import code path — and
cross-checked against a real 499-experience export. See
Regenerating this document.
This describes the journal export (experiences and ingestions). The substance database (
substances.json) is a completely different file; see the repo README.
{
"exportSource": "Android Journal 15.5",
"experiences": [
{
"title": "10 June 2026",
"text": "",
"creationDate": 1782432377000,
"sortDate": 1781049975000,
"isFavorite": false,
"ingestions": [
{
"substanceName": "Ketamine",
"time": 1781049975000,
"endTime": null,
"creationDate": 1782432377000,
"administrationRoute": "INSUFFLATED",
"dose": 60.0,
"isDoseAnEstimate": false,
"estimatedDoseStandardDeviation": null,
"units": "mg",
"notes": "",
"stomachFullness": null,
"consumerName": null,
"customUnitId": null,
"isHiddenInTimeline": false
}
],
"location": null,
"ratings": [],
"timedNotes": []
}
],
"substanceCompanions": [{ "substanceName": "Ketamine", "color": "BROWN" }],
"customUnits": []
}A complete two-experience file is checked in at
samples/v15-minimal.json.
The app serializes with Json { encodeDefaults = true; explicitNulls = true; ignoreUnknownKeys = true; decodeEnumsCaseInsensitive = true; allowTrailingComma = true } (prettyPrint is left off). In practice:
| Rule | Consequence |
|---|---|
encodeDefaults = true |
Every field of every object is written, even when it equals its default. Exports are never sparse. |
explicitNulls = true |
Absent values are written as null rather than omitted. |
prettyPrint = false |
The file is one minified line with no spaces. |
ignoreUnknownKeys = true |
On import, keys the model doesn't know are silently dropped — extra fields are safe, but see Legacy detection for the two keys that are inspected before parsing. |
decodeEnumsCaseInsensitive = true |
"oral", "Oral" and "ORAL" all import. |
allowTrailingComma = true |
A trailing comma in a hand-edited file is tolerated. |
Timestamps (time, endTime, creationDate, sortDate) are
epoch-milliseconds integers (UTC), serialized from java.time.Instant.
"Required" below means the serializer descriptor marks the field non-optional: it must be present in the file or the import fails with "Error when decoding: …". Optional fields may be omitted; the app itself never omits them.
| Field | Type | Required | Notes |
|---|---|---|---|
exportSource |
string | no | Provenance stamp, e.g. "Android Journal 15.5". Non-null. A value containing "legacy" makes the importer reject the file. |
experiences |
array of Experience | yes | The journal itself. |
substanceCompanions |
array of SubstanceCompanion | no | Per-substance colors. |
customUnits |
array of CustomUnit | no | User-defined dose units. |
There is no customSubstances key in v15 — its presence marks the file as
legacy and gets it rejected. See Import behaviour.
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | |
text |
string | yes | Free-form note; "" when empty. |
creationDate |
int (epoch-ms) | no | Defaults to now if omitted. |
sortDate |
int (epoch-ms) | yes | What the journal list sorts by (usually the first ingestion). |
isFavorite |
bool | no | |
ingestions |
array of Ingestion | no | |
location |
Location or null |
no | |
ratings |
array of Rating | no | Shulgin ratings. |
timedNotes |
array of TimedNote | no |
Experiences have no id in the export; ingestions, ratings and timed notes are nested inside the experience that owns them.
| Field | Type | Required | Notes |
|---|---|---|---|
substanceName |
string or null |
no | Matched by name against the substance database; unknown names still import. |
time |
int (epoch-ms) | yes | |
endTime |
int (epoch-ms) or null |
no | Set for ingestions taken over a period. |
creationDate |
int (epoch-ms) or null |
no | |
administrationRoute |
AdministrationRoute | yes | |
dose |
number or null |
no | In units; null means "unknown dose". |
isDoseAnEstimate |
bool | yes | |
estimatedDoseStandardDeviation |
number or null |
no | Only meaningful when isDoseAnEstimate is true. |
units |
string or null |
no | Free text, e.g. "mg", "mL", "mg THC". |
notes |
string or null |
no | |
stomachFullness |
StomachFullness or null |
no | Oral ingestions only. |
consumerName |
string or null |
no | null = the phone's owner; any other name is a "consumer" shown separately. |
customUnitId |
int or null |
no | References customUnits[].id. |
isHiddenInTimeline |
bool | no |
| Field | Type | Required |
|---|---|---|
name |
string | yes |
latitude |
number or null |
no |
longitude |
number or null |
no |
A named location without coordinates is normal (both were null for some
entries in the real export).
| Field | Type | Required | Notes |
|---|---|---|---|
option |
ShulginRatingOption | yes | "threePlus". |
time |
int (epoch-ms) or null |
no | null marks the overall rating of the experience rather than a point in time. |
creationDate |
int (epoch-ms) or null |
no | |
isHiddenInTimeline |
bool | no |
All five fields are required.
| Field | Type | Notes |
|---|---|---|
creationDate |
int (epoch-ms) | |
time |
int (epoch-ms) | Where the note sits on the timeline. |
note |
string | |
color |
AdaptiveColor | |
isPartOfTimeline |
bool |
Both fields are required. One entry per substance the user has ever logged; it carries nothing but the display color.
| Field | Type | Notes |
|---|---|---|
substanceName |
string | Joins to ingestions[].substanceName. |
color |
AdaptiveColor |
A user-defined dose unit ("1 pill = 20 mg", "1 mL of a 10 mg/mL solution"),
referenced by ingestions[].customUnitId.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
int | yes | Referenced by customUnitId on ingestions and dose components. |
name |
string | yes | |
creationDate |
int (epoch-ms) | no | Non-null. |
isArchived |
bool | yes | |
unit |
string | yes | Singular, e.g. "pill". |
unitPlural |
string or null |
no | |
note |
string | yes | |
color |
AdaptiveColor or null |
no | |
areTimelinesOfSubcomponentsShown |
bool | no | |
areTolerancesOfSubcomponentsShown |
bool | no | |
halfToleranceInDays |
number or null |
no | |
zeroToleranceInDays |
number or null |
no | |
roaInfos |
array of RoaInfo | no | Custom duration/dose curves, for units built on a custom substance. |
doseComponents |
array of DoseComponent | no | What one unit contains; more than one entry describes a blend. |
| Field | Type | Required | Notes |
|---|---|---|---|
id |
int | yes | |
creationDate |
int (epoch-ms) | yes | |
administrationRoute |
AdministrationRoute | yes | |
doseInfo |
object | yes | { "lightMin", "commonMin", "strongMin", "heavyMin" }, all numbers, all required. |
durationInfo |
object or null |
no | { "onset", "comeup", "peak", "offset" }, all required, each a duration range. |
A duration range is { "min": number, "max": number, "units": "SECONDS" | "MINUTES" | "HOURS" | "DAYS" } — all three required.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
int | yes | |
creationDate |
int (epoch-ms) | no | Non-null. |
dose |
number or null |
no | |
isEstimate |
bool | no | |
estimatedDoseStandardDeviation |
number or null |
no | |
notes |
string or null |
no | |
substanceName |
string or null |
no | |
substanceUnit |
string or null |
no | |
customUnitId |
int or null |
no | Set when a unit nests another unit. |
Enum values are plain JSON strings, matched case-insensitively on import.
Serialized as the enum name. In app order:
ORAL, SUBLINGUAL, BUCCAL, INSUFFLATED, SMOKED, INHALED, RECTAL,
TRANSDERMAL, SUBCUTANEOUS, INTRAMUSCULAR, INTRAVENOUS
An unrecognised route is a hard decode failure (unlike colors, there is no fallback).
| Enum constant | Value in the file |
|---|---|
EMPTY |
"EMPTY" |
QUARTER_FULL |
"QUARTERFULL" |
HALF_FULL |
"HALFFULL" |
FULL |
"FULL" |
VERY_FULL |
"VERYFULL" |
An unknown value throws "<value> is not a valid stomach fullness" and fails the
import. null is fine and is what non-oral ingestions carry.
| Scale | Value in the file |
|---|---|
| − | "minus" |
| ± | "plusMinus" |
| + | "plus" |
| ++ | "twoPlus" |
| +++ | "threePlus" |
| ++++ | "fourPlus" |
Serialized as the enum name (e.g. "INDIGO", "FIRE_ENGINE_RED"), read back
case-insensitively. Unknown or misspelled names do not fail the import — they
silently fall back to RED.
v15.5 defines 111 colors, in this order. The first twelve are the Apple system colors the app started out with; the rest were added later and are reachable through the color picker:
ORANGE, YELLOW, RED, GREEN, MINT, TEAL, CYAN, BLUE, INDIGO,
PURPLE, PINK, BROWN, FIRE_ENGINE_RED, CORAL, TOMATO, CINNABAR,
RUST, ORANGE_RED, AUBURN, SADDLE_BROWN, DARK_ORANGE, DARK_GOLD,
KHAKI, BRONZE, GOLD, OLIVE, OLIVE_DRAB, DARK_OLIVE_GREEN,
MOSS_GREEN, LIME_GREEN, LIME, FOREST_GREEN, SEA_GREEN,
JUNGLE_GREEN, LIGHT_SEA_GREEN, DARK_TURQUOISE, DODGER_BLUE,
ROYAL_BLUE, DEEP_LAVENDER, BLUE_VIOLET, DARK_VIOLET, HELIOTROPE,
BYZANTIUM, MAGENTA, DARK_MAGENTA, FUCHSIA, DEEP_PINK,
GRAYISH_MAGENTA, HOT_PINK, JAZZBERRY_JAM, MAROON, GARNET_NOIR,
SMOKED_ROSEWOOD, ROSE_QUARTZ, SUNSET_APRICOT, HONEY_SAFFRON,
MIDNIGHT_OLIVE, NEON_LEMON, ELECTRIC_CHARTREUSE, MOSS_APPLE,
SPRING_BUD, PALE_PISTACHIO, SILVER_SAGE, FROSTED_MINT, BLACK_PINE,
SEAFOAM_JADE, TROPICAL_AQUA, DEEP_HARBOR_BLUE, STORM_SLATE_BLUE,
ABYSSAL_NAVY, MIDNIGHT_COBALT, SATURATED_COBALT, LASER_BLUE,
DUSTY_PERIWINKLE, SOFT_PERIWINKLE, COTTON_CANDY_MAGENTA, LILAC_MIST,
ELECTRIC_ORCHID, DEEP_PLUM_WINE, RADIANT_MULBERRY, BLACK_CHERRY,
ACID_LIME, WASABI_ZING, CITRUS_LEAF, VERDANT_CHARTREUSE, KIWI_PUNCH,
PERIDOT_FLARE, APPLE_ZEST, LIME_SPARK, HERBAL_NEON, CLOVER_GLOW,
AUREATE_GOLD, SAFFRON_BLAZE, AMBER_BURST, MARIGOLD_FLARE,
SUNLIT_OCHRE, BRONZED_HONEY, CELESTIAL_AZURE, COBALT_SURGE,
DEEP_SKY_CERULEAN, ELECTRIC_SAPPHIRE, MIDNIGHT_AZURE, ULTRAMARINE_GLOW,
AZURE_TIDE, CERULEAN_SURGE, COBALT_CURRENT, SAPPHIRE_WAVE,
DEEP_AZURE_EDGE, ROYAL_COBALT, ELECTRIC_COBALT, NIGHT_AZURE
The importer is destructive and says so: "Import a file that was exported before. Note that this will delete the data that you already have in the app." It replaces the database rather than merging.
Before parsing, the file is read as a generic JSON object and screened:
- If the top-level object has a
customSubstanceskey → rejected as "Legacy file not compatible: The selected file contains legacy data that is not compatible with the new format, so it cannot be imported." - Else if
exportSourceis a string containing"legacy"(case-insensitive) → same rejection. - Otherwise it is decoded as
JournalExport. A decode error surfaces as "Decoding file failed / Error when decoding: …".
Because of (1), a v11 file cannot simply be topped up with the new fields — the
customSubstances array must be removed, which is what
tools/convert_v11_to_v15.py does (and why the custom-substance definitions are
lost; the ingestions that reference them still import).
Exported files are named Journal <dd MMM yyyy>.json (the legacy export writes
Journal Legacy <dd MMM yyyy>.json) and are shared as application/json.
v15 can also write — but not read — the old format, via Legacy export
("outputs data in a Version 11.11 compatible format; some data cannot be
converted because that version supports fewer features"). It stamps
exportSource with "Journal Legacy <version>", which is exactly what trips
check (2) above.
Differences from v15, per the Legacy*Serializable descriptors:
- Top level:
experiences,substanceCompanions,customSubstances,customUnits— all optional; noexportSourcefield in the model itself. customSubstances[]:{ "name", "units", "description" }, all required.customUnits[]: serialized as empty objects ({}) — the legacy model carries no fields.- Ingestions:
substanceName,unitsandnotesare required (non-null), and there is noisHiddenInTimeline. - Ratings: no
isHiddenInTimeline. - Experiences: same fields as v15, only the field order differs (
locationlast).
Field names are otherwise identical, which is why converting v11 → v15 is a matter of filling in defaults rather than renaming anything.
The export is a flattened view of the app's Room database
(/data/data/<applicationId>/databases/experiences_db). Notable differences:
- Row ids are not exported (except
CustomUnit.id, which the export needs to resolvecustomUnitIdreferences). Ingestion,ShulginRatingandTimedNoterows carry anexperienceIdforeign key in the database; in the export they are nested inside their experience.Locationis not a table —name/latitude/longitudeare columns onExperience, re-nested into alocationobject on export.CustomRoaInfoandDoseComponentare separate tables, nested intoroaInfos/doseComponentson export.CustomSubstanceis still a table on device; it simply has nowhere to go in the v15 export format.
# 1. Field lists + optional flags: read the generated $serializer classes
jadx -j 4 --no-res --no-debug-info -d /tmp/jadx Journal.apk
grep -rl 'settings.IngestionSerializable"' /tmp/jadx/sources # -> the $serializer
# each kk3Var.k("field", isOptional) call is one addElement()
# childSerializers() shows nullability: hx3.w(x) wraps a nullable serializer
# 2. Enum values: R8 unboxes them, so read the constant names from smali
apktool d -f --no-res -o /tmp/apkt Journal.apk
grep -m1 'const-string v0, "' /tmp/apkt/smali/<enum-subclass>.smali
# 3. Json configuration: JsonConfiguration.toString() in smali names each
# surviving field, and the Json { } lambda shows which are set to true
# 4. Room DDL (for the storage mapping): the CREATE TABLE strings are in the dex
python3 -c "import zipfile;open('/tmp/c.dex','wb').write(zipfile.ZipFile('Journal.apk').read('classes.dex'))"
grep -ao 'CREATE TABLE IF NOT EXISTS `[^;]*' /tmp/c.dex