Skip to content

Latest commit

 

History

History
385 lines (311 loc) · 16.5 KB

File metadata and controls

385 lines (311 loc) · 16.5 KB

The v15 journal export format

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.

At a glance

{
  "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.

Encoding rules

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.

Top level — JournalExport

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.

Experience

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.

Ingestion

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

Location

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).

Rating

Field Type Required Notes
option ShulginRatingOption yes ⚠️ camelCase wire value, e.g. "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

TimedNote

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

SubstanceCompanion

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

CustomUnit

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.

RoaInfo

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.

DoseComponent

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.

Enumerations

Enum values are plain JSON strings, matched case-insensitively on import.

AdministrationRoute

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).

StomachFullness

⚠️ Not the enum constant name — a custom serializer writes an underscore-less text form:

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.

ShulginRatingOption

⚠️ Also a custom serializer, writing camelCase, not the constant name:

Scale Value in the file
"minus"
± "plusMinus"
+ "plus"
++ "twoPlus"
+++ "threePlus"
++++ "fourPlus"

AdaptiveColor

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

Import behaviour

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:

  1. If the top-level object has a customSubstances key → 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."
  2. Else if exportSource is a string containing "legacy" (case-insensitive) → same rejection.
  3. 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.

The legacy (v11.11) shape

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; no exportSource field in the model itself.
  • customSubstances[]: { "name", "units", "description" }, all required.
  • customUnits[]: serialized as empty objects ({}) — the legacy model carries no fields.
  • Ingestions: substanceName, units and notes are required (non-null), and there is no isHiddenInTimeline.
  • Ratings: no isHiddenInTimeline.
  • Experiences: same fields as v15, only the field order differs (location last).

Field names are otherwise identical, which is why converting v11 → v15 is a matter of filling in defaults rather than renaming anything.

How it maps to on-device storage

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 resolve customUnitId references).
  • Ingestion, ShulginRating and TimedNote rows carry an experienceId foreign key in the database; in the export they are nested inside their experience.
  • Location is not a table — name/latitude/longitude are columns on Experience, re-nested into a location object on export.
  • CustomRoaInfo and DoseComponent are separate tables, nested into roaInfos / doseComponents on export.
  • CustomSubstance is still a table on device; it simply has nowhere to go in the v15 export format.

Regenerating this document

# 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