An Android client for iCloud Notes. Reverse-engineered from CloudKit's private DB
API + Apple's topotext CRDT proto. Reads and writes notes that round-trip with
Mac's Apple Notes and iCloud.com.
This is a research codebase, not a polished product. Treat the README as a field report for the next person (or agent) who picks it up.
Grab a signed APK from the Releases page and side-load it onto an Android 8.0+ device. Each release's notes link back to the disclaimer below; read it first.
Note for early v0.0.1 users: v0.0.1 was signed with an unstable CI key, so upgrading directly to v0.0.2+ will fail with "Something went wrong / app could not be installed." Uninstall v0.0.1 first, then install v0.0.2. From v0.0.2 onwards every release uses a pinned project-owned key, so future upgrades install cleanly.
If you'd rather build from source, see Build / run further down.
This is alpha-quality software. It might:
- corrupt, duplicate, or delete notes in your iCloud account
- drop formatting in subtle ways when round-tripping through Mac
- stop working at any time if Apple changes their API
- fail to handle modern collaborative notes (read-only at best)
It is unaffiliated with Apple — it talks to Apple's CloudKit Web Services API as a third-party client using your own credentials. The proto formats are inferred by observation, not from any Apple documentation. There is no warranty of any kind. If you have notes you can't afford to lose, back them up first or just don't use this app.
The first launch will block the UI behind a dialog you have to acknowledge so you can't say you weren't warned.
PRs and improvements are very welcome! Bug reports are OK, but I'd much rather have a bug report with an associated PR fixing the issue 😉 — this is a weekend project I'm not on call for. The reverse-engineering surface area is huge (substring CRDT, MergeableData CRDT, table object graph, image fetch pipeline, paragraph + inline attribute_runs) and there's a long tail of edge cases waiting to be discovered. If you investigate one, please write down what you find in the README's relevant section so the next person doesn't have to rediscover it.
| Capability | Status |
|---|---|
| Sign in with Apple ID (cookie-based) | Works |
| List notes (recents, paginated) | Works |
| Folder navigation (sidebar, trash filter) | Works |
| Read note content (decode topotext proto) | Works for NOTE_STORE_PROTO; not the modern MergableData shape |
| Append text | Works (round-trips with Mac) |
| Mid-text splice (insert/replace/delete) | Works (matches iCloud.com's slot-promotion pattern) |
| Create new note (FAB → save) | Code path works (deferred-create + v4 UUID); end-to-end Mac round-trip not yet user-confirmed |
| Auto-save on lifecycle pause | Works (lifecycle ON_PAUSE only — see AppleNotesApp.kt:1052) |
| Share OUT (Android intent chooser) | Works (plaintext only) |
| Delete | Uses CloudKit forceDelete; Mac surfaces as "Recently Deleted" (server-managed, not a hard wipe) |
| Format display (paragraph styles + inline) | Works — Title / Heading / Subheading / Body / Monospaced; Bulleted / Dashed / Numbered / Checkbox; bold / italic / underline / strikethrough; clickable links |
| Table attachments | Decoded from MergeableDataEncrypted (CRDT graph), rendered as a real grid |
| Image attachments | Fetched as CKAsset bytes from the Media reference, rendered inline |
| Other attachments (sketches, maps, etc.) | Rendered as a labeled placeholder card |
| Format input toolbar | Works — paragraph style picker, list toggles (bullet/dash/numbered/checkbox), inline B/I/U/S, link |
| Tap-to-toggle checkbox | Works in formatted view (auto-saves) |
| Conflict UX | Auto-retry on CONFLICT/oplock; no concurrent-edit merge yet |
Strong hypothesis: Apple's replica UUID must be RFC 4122 v4. This is the
last byte-level difference between an Android-created note that got trashed
(ab-fresh-162944) and an iCloud.com-created note that survived (ic-create-test-001).
Both had identical proto structure, same fields, same compression. Only the
UUID's version nibble differed.
When we created a note from Android with our previous (raw-random) UUID, Mac's
notesync added housekeeping fields, left the body proto bytes intact, and
silently set Deleted=1 within a few minutes — the note appeared in
Recently Deleted on Mac. iCloud.com-created notes (which use v4 UUIDs) survive
the same flow.
Caveat: we shipped the v4 UUID fix in commit 3ee1a60 but haven't yet
confirmed end-to-end that a v4-UUID-created note survives Mac quit/reopen. If
you pick this up and v4 turns out not to be the only thing Mac validates, the
next things to investigate are (a) the substring tree shape (iCloud.com always
emits multiple substrings with tombstone separators around line breaks; we emit
one substring), (b) attribute_runs count (iCloud.com splits at trailing \n;
we don't), and (c) the per-replica counter2 field (iCloud.com uses a
non-trivial value; we use 1).
SecureRandom().nextBytes(16) produces raw bytes — only ~1/16 chance the version
nibble is 4. Use java.util.UUID.randomUUID(), or set the bits explicitly:
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
bytes[6] = ((bytes[6].toInt() and 0x0F) or 0x40).toByte() // version = 4
bytes[8] = ((bytes[8].toInt() and 0x3F) or 0x80).toByte() // variant = 10See DeviceIdentity.kt
and commit 3ee1a60.
- Endpoint:
https://p<N>-ckdatabasews.icloud.com.cnfor Chinese accounts,.comotherwise. Path:/database/1/com.apple.notes/production/private/.... - Auth: cookie-based (
X-APPLE-WEBAUTH-TOKEN). We harvest cookies via Android'sWebViewflow inauth/. - Zone:
Notes. Record types:Note,Folder,SearchIndexes. - Required fields on
Notecreate:TextDataEncrypted,TitleEncrypted,SnippetEncrypted,Folder(CKReference),CreationDate,ModificationDate. - Date trap: omit
ModificationDateand CloudKit defaults it to .NETDateTime.MinValue(-62135769600000ms — year 1). Mac silently skips records with that sentinel. Always sendSystem.currentTimeMillis(). recordChangeTagis CloudKit's optimistic-concurrency token. Mac's notesync bumps it asynchronously after merging — expectCONFLICT/oplockerrors on edits that follow a create. We auto-retry once with a fresh tag.
recordType=Notequery works.recordType=Folderquery is rejected by the server: "Type is not marked indexable: Folder". Use/records/lookupwith recordNames pulled from notes'Folderreferences instead.SearchIndex"recents" index exists and works. "folders" returns "No index of this name exists".- Special folder recordNames:
DefaultFolder-CloudKit("Notes"),TrashFolder-CloudKit("Recently Deleted"). - Notes in trash live under
Folder = TrashFolder-CloudKit. TheDeletedfield is added by Mac housekeeping; don't filter by it. Filter by Folder ref.
TextDataEncrypted is gzipped (or zlibbed) protobuf. The shape:
versioned_document.Document {
Version version = 2 {
int32 minimumSupportedVersion = 2;
bytes data = 3; // encoded topotext.String
}
}
topotext.String {
string string = 2; // the visible text, concatenation of live substrings
repeated Substring substrings = 3;
VectorTimestamp timestamp = 4; // per-replica clocks
repeated AttributeRun attributeRuns = 5;
}
Substring {
CharID charID = 1; // (replicaID, clock) — globally unique
uint32 length = 2;
CharID timestamp = 3; // for tombstone fresh-ts ordering
bool tombstone = 4;
repeated int32 child = 5; // forward links into substrings[]
}
CharID {
uint32 replicaID = 1; // 1-based index into VectorTimestamp.clock
uint32 clock = 2; // Lamport
}
Invariants (verified by sampling iCloud.com-created and Mac-created notes):
- The substring array always starts with doc-start (
charID=(0,0),length=0) and ends with the sentinel (charID=(0,0xFFFFFFFF),length=0). Don't reorder. - Children are forward links into the array. The walk produces the visible-order chain: doc-start → ... → sentinel.
- New inserts go between doc-start and sentinel; tombstones flip
tombstone=truebut stay in the tree.
- Each character ever inserted has a globally-unique
(replicaID, clock)ID.replicaID=1is whoever wrote this version of the proto; the same UUID may be at a different slot in someone else's proto. - Slot rotation matters: when iCloud.com edits a note, it always remaps
itself to slot 1, demoting everyone else. Mac's notesync uses the rotation as
a "refresh local cache" signal. We mirror this in
NoteAppender.setBodySpliceBytes. ReplicaIDToNotesVersionDataEncryptedis the per-replica version vector. Mac uses changes to this field as the cache-invalidation signal. Pass it through unchanged on modify (don't strip or rewrite). Without this, Mac duplicates the body on multi-substring notes after Android edits.
Both gzip (1f 8b) and zlib (78 9c) are accepted. iCloud.com sends zlib for
short notes; Mac sends gzip with OS=0x13. We send gzip with OS=0xff (Java
default). All three round-trip cleanly. Compression format is not the cause
of Mac trashing — we ruled this out by sampling.
U+2028(LINE SEPARATOR) andU+2029(PARAGRAPH SEPARATOR) appear in Mac notes for soft line breaks inside lists/checkboxes. Don't strip them.(U+FFFC OBJECT REPLACEMENT) is Apple's placeholder for inline attachments. We pass through but don't render the attachment.
| Code | Style |
|---|---|
| 0 | Title (also: implicit for the first line) |
| 1 | Heading |
| 2 | Subheading |
| 3 | Body (also: f1 absent → Body) |
| 4 | Monospaced |
| 100 | Bulleted list |
| 101 | Dashed list |
| 102 | Numbered list |
| 103 | Checkbox list |
(The earlier draft of this README listed 0=Body / 1=Title / 101=Checkbox. Wrong: verified by sampling a baseline note that contained one of every style.)
Checkbox checked-state lives at AttributeRun.f2.f5 — a 20-byte sub-message
containing {f1: 16-byte UUID, f2: 0|1 (done flag)}, NOT in AttributeRun.f2.f4.
| Field | Meaning |
|---|---|
f5 (varint) |
font weight enum: 1=Bold, 2=Italic, 3=BoldItalic |
f6 (varint) |
underlined (bool) |
f7 (varint) |
strikethrough (bool) |
f9 (bytes) |
link URL (UTF-8 string) |
f12 (msg) |
attachment_info {f1: UUID-string, f2: UTI-string} (table/image/etc.) |
Decode via NoteBodyEditor.parseAttributeRuns.
Re-encode via NoteBodyEditor.encodeAttributeRunField.
Tables are stored as separate Attachment CKRecords (UTI
com.apple.notes.table). Their content lives in MergeableDataEncrypted — a
zlib-deflate'd protobuf encoding Apple's MergeableData CRDT graph. The graph
nodes form a typed object soup:
- KeyItems (field 4): string field names —
self,crRows,crColumns,cellColumns,crTableColumnDirection,identity,UUIDIndex. - TypeItems (field 5): string type names —
com.apple.CRDT.NSString,com.apple.CRDT.NSUUID,com.apple.notes.ICTable, etc. Indexed. - UUIDItems (field 6): 16-byte UUIDs. Indexed.
- GraphObjects (field 3): repeated, indexed. Each is one of:
f1List,f6Dictionary,f10String (with f2 = current text),f13CustomMap (typed map, like ICTable),f16OrderedSet.
The root ICTable's CustomMap has entries crRows (OrderedSet of row UUIDs),
crColumns (OrderedSet of column UUIDs), and cellColumns (Dict of
col_uuid → Dict of row_uuid → string_obj_id). Walk those to extract a 2D
grid; resolve each string_obj_id to its NSString.currentText.
See MergeableDataDecoder.kt.
The decoder uses cellColumns iteration order as the column order and
first-appearance order across columns as the row order — crRows/crColumns
OrderedSets carry the canonical visual order via a separate UUIDIndex layer
that we don't fully reconstruct yet, but the iteration order matches the
visual order for tables created left-to-right top-to-bottom.
Image Attachment records carry a Media field that's a CKReference to a
separate "Media" record. Look up the media record; its Asset (or
MediaEncrypted) field is a CKAsset whose value.downloadURL is the binary's
signed download URL. GET it with the session's cookies.
These were dead ends; documenting so you don't repeat them.
- Markdown rendering as a stopgap. Tempting because it's easy, but Apple's
notes don't have markdown syntax in their body bytes — they have
attributeRunsmetadata. Rendering markdown on the Android side would mean literal**bold**showing up everywhere on Mac. Use attribute_runs, even with partial coverage. - Empty
createNote(title="", body="")thenmodifyNoteBody. This races Mac's notesync housekeeping and produces records that get trashed within minutes. Even with the v4 UUID fix this is risky. Defer createNote until the first save with content (we do; see commit55704ae). - Querying for folders by
recordType=Folder. Server rejects with "Type is not marked indexable: Folder". Use/records/lookupwith recordNames pulled from notes' Folder refs. - Trusting CloudKit's
Deletedfield as the trash discriminator. Mac housekeeping adds it, but it's also present on alive notes. Filter by Folder ref pointing atTrashFolder-CloudKitinstead. fetchRecents(1). The server returns 0 results for tiny limits even when notes exist. Use ≥ 50.- Driving the FAB via
adb shell input tapcoordinates. Brittle — emulator scaling means coords drift between sessions. Useuiautomator dumpif you must, or just exercise the same code paths viaDebugReceiverbroadcasts. - Recreating the device's replica UUID per session. Apple has a per-note replica cap and your registry will explode. Persist once, reuse forever.
- Skipping
ReplicaIDToNotesVersionDataEncryptedon modify. Mac duplicates the body locally after multi-substring edits. Always pass it through.
| What | Test |
|---|---|
| Read existing Mac notes | Open any note in the app — body should match Mac |
| Append edit round-trips | Add text on Android → save → check Mac after relaunch |
| Mid-text splice round-trips | Edit existing text on Android → save → verify on Mac |
| Create new note with v4 UUID | Tap FAB → type → back → verify note appears (and stays) on Mac |
| Auto-save | Type, swipe back without explicit save → reopen → text persists |
| Share OUT | Detail screen → share button → Android chooser appears |
| Folder filter | Drawer → select folder → list filters; trash hidden by default |
The DebugReceiver exposes the same code paths via adb broadcasts:
# List all notes
adb shell am broadcast -p com.example.applenotes -a com.example.applenotes.LIST
# Look up a specific note by title
adb shell am broadcast -p com.example.applenotes -a com.example.applenotes.LOOKUP_BY_TITLE --es title 'foo'
# Append to an existing note
adb shell am broadcast -p com.example.applenotes -a com.example.applenotes.APPEND_BY_TITLE \
--es title 'foo' --es text 'extra line'
# Create a new note
adb shell am broadcast -p com.example.applenotes -a com.example.applenotes.CREATE \
--es title 'newnote' --es body 'hello'Watch with adb logcat -s AppleNotesClient:* AppleNotesDebug:*.
app/src/main/java/com/example/applenotes/
├── auth/ WebView cookie harvest, session refresh, DeviceIdentity (v4 UUID!)
├── client/ AppleNotesClient: HTTP layer, fetchRecents/lookupNote/createNote/modifyNoteBody
├── proto/
│ ├── ProtobufWire.kt Hand-rolled protobuf wire format (no schema dep)
│ ├── Gzip.kt gzip + zlib detect/compress/decompress
│ ├── NoteBodyEditor.kt Decode topotext.String → visible text + attribute runs
│ ├── NoteAppender.kt Append / splice / setBody — produces new proto bytes
│ └── NoteCreator.kt Build proto for a fresh note
├── debug/DebugReceiver.kt Broadcast-driven CLI for testing without UI
└── ui/AppleNotesApp.kt Compose UI: list, detail, FAB draft state, drawer, save flow
Read order if you're new:
NoteBodyEditor.kt— get a feel for what a substring tree looks like.AppleNotesClient.kt— see the wire shape of every CloudKit op.NoteAppender.setBodySpliceBytes— the slot-promotion pattern in detail.AppleNotesApp.kt—ScreenState.Detailand the deferred-create FAB flow.
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n com.example.applenotes/.MainActivityYou'll need an Android emulator or device with Google Play Services. Sign in with an Apple ID that has Notes enabled in iCloud. The first launch goes through the WebView auth flow.
Use iCloud.com web for A/B comparisons. When something doesn't work, the fastest way to figure out what Apple expects is to make iCloud.com produce the same operation on a parallel test note, then diff the bytes. We caught the v4 UUID issue this way after exhausting other theories.
Sample multiple healthy notes before drawing conclusions. Bisha and Money maker (Mac-created, alive) gave us a baseline. ic-create-test-001 (iCloud.com-created, alive) gave us another. Newtest1-4 and Made-in-android (Android-created, trashed) showed the failure mode. The trashed ones had attributes the alive ones never had — but only the UUID was a consistent discriminator across all of them.
Don't trust a single failed test. Mac trashes records asynchronously, on notesync, on quit/reopen — sometimes minutes after creation. A note that "looks fine right after save" may still get trashed an hour later when Mac next runs notesync. Wait, then re-check.
The CRDT is real. char-IDs are stable across remote edits. Insertions slot
in by parent char-ID, not position. Tombstones are commutative. If you're
writing new code that touches the proto, encode operations as (parentCharID, chars) or (charIDs to tombstone), not as positional diffs. This makes the
v2 conflict-resolution work (re-splice on stale base) trivial — see the
"What this means for our code" discussion in commit history.
Match iCloud.com's payload shape, not just the field names. When in doubt,
look at what iCloud.com sends. We removed PaperStyleType / AttachmentViewType
/ Deleted from createNote because iCloud.com doesn't send them — server
defaults handle it. Sending zeros made the new record look "edited" and racy.
Pre-flight checks before edits. Always re-lookupNote to get a fresh
recordChangeTag before submitting a modify, OR rely on the auto-retry on
CONFLICT (we do the latter). The post-create race is real and consistent.
Write logs that show wire shape. summarizeBase64 in NoteBodyEditor dumps
ops/replicas/attr_runs in one line. That's how we caught most issues. When you
add a new field, add a corresponding summary line.
Don't speculate about validation rules in production. Apple's clients run
validation we can't see. The only authoritative way to know if a write is
accepted is to watch what Mac does to it over time. Build the iteration loop
short — adb broadcast → read response → lookupNote again — and you'll find
issues fast.
Read PLAN.md before adding new features. It records decisions that are
not obvious from the code (markdown vs attribute_runs, lifecycle-only auto-save,
empty-body new-note flow). Don't relitigate without reason.
- Concurrent-edit conflicts: the auto-retry resubmits the original body
bytes, which last-writer-wins on a true concurrent edit. Safe for the
post-create housekeeping race we hit in practice. The proper v2 fix is to
decompose edits into char-ID-keyed ops and replay against a fresh base on
conflict; the design is sketched in commit
0462bcb's discussion thread but not yet implemented. - Modern
MergableDataproto is not supported. Newer Apple Notes (iOS 17+ collaborative notes?) use a different shape. We refuse to operate on those — seeNoteBodyEditor.probe. Read-only support would be a starting point. - Image attachments display as
. Decoding theAttachmentCKRecord type is not implemented. - No formatting toolbar yet. We decode paragraph styles but the editor only shows plain text. UI work pending (Phase D2 in PLAN.md).
- No subscription / push. Polls when the user opens the app. Background sync, CloudKit subscriptions, and a send queue are all unimplemented.
CloudKit serializes TitleEncrypted, SnippetEncrypted, and TextDataEncrypted
as ENCRYPTED_STRING / ENCRYPTED_BYTES types — but the actual byte values
sent over the wire are just base64-encoded UTF-8 (for Title/Snippet) and
gzipped protobuf (for TextData). There is no client-side encryption layer
involved. The names appear to be a CloudKit historical artifact; iCloud's E2EE
("Advanced Data Protection") is a separate keystore mechanism we're not
exercising.
If you kotlin.io.encoding.Base64.decode(titleField.value).decodeToString()
you get the cleartext title. That's the entire "decryption" step.
This project is unaffiliated with Apple. It uses Apple's public CloudKit Web Services API with the user's own credentials. It reads and writes notes the authenticated user already has access to. The proto formats were inferred from observation of round-tripping the user's own data through their own iCloud account; no Apple-internal documentation or reverse-engineered binaries were consulted.
If you're going to publish anything based on this, be respectful. Don't redistribute captured proto data from other users' accounts. Don't ship anything that would encourage account credential sharing.