Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,28 @@ jobs:
# here, and fails with 403 "Resource not accessible by integration" after the APK has
# already been built and signed. That is exactly how the 1.0.5 exporter release went out
# with no assets attached; this workflow had the same wiring and was never re-run.
# The APK carries libmacadi.so, which links Unicorn (GPL-2.0). GPL-2.0 section 3 asks for the
# corresponding source to be offered from the same place as the binary, and FetchContent
# downloads Unicorn's at build time rather than it being in this repository's tag. So the
# exact tarball the build used goes on the release beside the APK, checked against the hash
# MacAdi.cmake pins, and the APK's assets/licences/unicorn/NOTICE.txt says where it is.
- name: Fetch Unicorn's source, as the APK was built from it
if: github.event_name == 'release'
run: |
url=$(sed -n 's/^ *URL \(https:[^ ]*unicorn[^ ]*\.tar\.gz\)$/\1/p' app/src/main/cpp/macadi/MacAdi.cmake)
sha=$(sed -n 's/^ *URL_HASH SHA256=\([0-9a-f]*\).*/\1/p' app/src/main/cpp/macadi/MacAdi.cmake)
test -n "$url" && test -n "$sha"
curl -sSfL "$url" -o "unicorn-$(basename "$url")"
echo "$sha unicorn-$(basename "$url")" | sha256sum -c -
echo "UNICORN_SOURCE=unicorn-$(basename "$url")" >> "$GITHUB_ENV"

- name: Upload APK Release Asset to GitHub Release
uses: softprops/action-gh-release@v2
if: github.event_name == 'release'
with:
files: ${{ env.main_project_module }}/build/outputs/apk/release/${{ env.app_name }}-${{ steps.extract_version.outputs.APP_VERSION }}.apk
files: |
${{ env.main_project_module }}/build/outputs/apk/release/${{ env.app_name }}-${{ steps.extract_version.outputs.APP_VERSION }}.apk
${{ env.UNICORN_SOURCE }}
name: ${{ env.app_name }} Android App ${{ steps.extract_version.outputs.APP_VERSION }}
token: ${{ secrets.ANDROID_BUILD_RELEASE_GITHUB_TOKEN }}

Expand Down
80 changes: 80 additions & 0 deletions .github/workflows/macadi-host.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Emulated Mac (host)

# The emulated Mac's native core, built for the host and run against Apple's real macOS CoreADI.
#
# Builds app/src/main/cpp/macadi/ exactly as the app does (MacAdi.cmake is shared), fetches the
# library with the same range request the app makes, checks both pinned hashes, and has the
# emulator load it and answer "not provisioned". Nothing is sent to Apple but that one download:
# provisioning, which would register a machine, is not run.

on:
pull_request:
branches: [ "main" ]
paths:
- "app/src/main/cpp/macadi/**"
- "app/src/test/macadi-host/**"
- "app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi*.java"
- "scripts/locate_mac_coreadi.py"
- "scripts/adi-emulator-diff/**"
- ".github/workflows/macadi-host.yml"
push:
branches: [ "main" ]
paths:
- "app/src/main/cpp/macadi/**"
- "app/src/test/macadi-host/**"
- "app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi*.java"
- "scripts/locate_mac_coreadi.py"
- "scripts/adi-emulator-diff/**"
- ".github/workflows/macadi-host.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
host:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Fetch Apple's macOS CoreADI the way the app does, and check both hashes
run: python scripts/locate_mac_coreadi.py --remote --out "$RUNNER_TEMP/CoreADI.x86_64"

- name: Build the native core for the host
run: |
cmake -S app/src/test/macadi-host -B "$RUNNER_TEMP/macadi" -DCMAKE_BUILD_TYPE=Release
cmake --build "$RUNNER_TEMP/macadi" --parallel

- name: Load the library and ask whether it is provisioned
run: |
out=$("$RUNNER_TEMP/macadi/macadi_cli" "$RUNNER_TEMP/CoreADI.x86_64" 979a0 /nonexistent \
00112233445566778899aabbccddeeff -1 --load-only)
echo "$out"
test "$out" = '{"loaded": true, "provisioned": false}'

# The C++ port against the Rust it was ported from, byte for byte: os-findmy-e's adi-core and
# app/src/test/macadi-host/macadi_diff.cpp make the same offline CoreADI calls with a frozen
# clock and a fixed host UUID, and the outputs must be identical. A difference is a port bug a
# successful sign-in would not show. Nothing is sent to Apple beyond the library download.
against-adi-core:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Fetch Apple's macOS CoreADI the way the app does, and check both hashes
run: python scripts/locate_mac_coreadi.py --remote --out "$RUNNER_TEMP/CoreADI.x86_64"

# unicorn-engine-sys runs bindgen, which needs libclang.
- name: Install libclang
run: sudo apt-get update -qq && sudo apt-get install -y -qq libclang-dev

- name: Compare libmacadi with adi-core
run: scripts/adi-emulator-diff/compare-libmacadi.sh "$RUNNER_TEMP/CoreADI.x86_64" "" "$RUNNER_TEMP/diff"
81 changes: 79 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ python -m venv .venv && .venv/bin/pip install "FindMy==<pinned version>"
the dependency tree resolve; every method raises. The stub is not a step toward enabling
`aniLocal`, and never will be.
- **Unicorn ships as native code, GPL-2.0 and all, and that was decided rather than missed.**
The emulated Mac (#274, in review) runs it in `libmacadi.so`. Before replacing it with a permissive
The emulated Mac (rule 21) runs it in `libmacadi.so`. Before replacing it with a permissive
emulator for licence reasons, read
[docs/emulated-mac-emulator-choice.md](./docs/emulated-mac-emulator-choice.md): a replacement
is invisible to users **only if it produces the same bytes**, because the emulator is not part
of the machine identity but its output is. `scripts/adi-emulator-diff/` is the check, and it has
not yet been run over the OTP path. The same doc says what the GPL obliges: a notice, and the
not yet been run over the OTP path (both drivers take a provisioned store for that). The same doc says what the GPL obliges: a notice, and the
library kept in its own process.
- **The app does run ADI locally, by a different route.** `app/src/main/java/.../anisette/`
loads Apple's real Android ADI libraries in-process and produces Anisette here, falling
Expand Down Expand Up @@ -702,6 +702,83 @@ already sent you, and then reasoning from a guess while you wait.
had a phone number blacked out by hand, and the pixels either side of it were not. Quote the
error text, never the surrounding screen, and never re-upload the image.

### 21. Anisette comes from an emulated Mac, and it is one machine for the life of the install

Since 2026-10-12 Apple issues the Find My token (`searchPartyToken`) only to a sign-in whose
Anisette machine was minted by a Mac or an iPhone; an Android-minted one gets `mmeAuthToken` alone
and every report fetch answers 401 ([malmeloo/FindMy.py#278](https://github.com/malmeloo/FindMy.py/issues/278)).
From about 2026-10-20 the edge also answers 503 to any `X-MMe-Client-Info` whose OS token is
`Android`, whatever else is sent ([#261](https://github.com/parawanderer/OpenTagViewer/pull/261)).
So `LocalAnisette`'s Android libraries can no longer reach Find My by any combination of headers.

`EmulatedMacAnisette` runs Apple's **macOS** CoreADI - the x86_64 build in iTunes 12.8.3 - under
Unicorn, in `libmacadi.so` (`app/src/main/cpp/macadi/`, ported from sacca97/os-findmy-e; see its
`NOTICE.md`). **A fresh sign-in gets it; a restored session keeps the machine that signed it in.**

- **Never move an existing session onto the emulated Mac.** A session `LocalAnisette` signed in
before 2026-10-12 still fetches reports, and its token only works with Anisette from the Android
machine that obtained it (#248). Handing it the emulated Mac at restore downloads CoreADI,
provisions a new Mac at app start, and sends that Mac's `X-Apple-I-MD-M` beside the account's
Android `X-Mme-Device-Id` - a working session made to answer 401. `AnisetteForSession` decides,
from `SessionProvenance`: `LocalAnisette` for every session but the emulated Mac's own, and
`EmulatedMacAnisette.Purpose.RESTORE` for those, which **never provisions** - a Mac that lost its
store is not the machine that holds the token. `RestoringASessionOnTheMachineThatSignedItInTest`
pins it.
- **Provenance has one home**: `sessionEstablishedLocally` in `LocalAnisette.PREFERENCES`, the key
every older install already has, plus `sessionMachine = emulatedMac` beside it for the Mac's
sessions. A new preferences file reads "nothing recorded" for every upgrader, which silently
turned off the machine-change warning in `_preferLocalAnisette`.
- **`libmacadi.so` is loaded only in `:macadi`.** `MacAdiService` hosts the emulator and the app
reaches it over binder through `RemoteMacAdi`; nothing in the app process may touch `MacCoreAdi`,
whose static initialiser loads the library. Two reasons: a SIGSEGV from Unicorn or Apple's code
is a native crash no `catch` sees (rule 4, #232), and the GPL-2.0 emulator must stay a separate
program from the Apache-2.0 libraries and the map SDKs. A death during a call arrives as
`MacAdi.EmulatorDiedException`, is recorded per app version and library build, and the Mac is not
tried again until one of those changes. `OpenAirTagApplication` returns early in that process,
as it does in `:adiprobe`. `TheEmulatedMacInItsOwnProcessTest` checks the boundary for real.

- **The library is fetched from Apple, never shipped.** One HTTP range request: in that exact disk
image the package payload is stored uncompressed and contiguous, so its offset is a constant.
`scripts/locate_mac_coreadi.py` re-derives and checks every constant, reading them from the Java
sources. Both the payload and the library are pinned by SHA-256, and the dispatcher offset
(`0x979A0`) is only valid for that one build.
- **One identity, kept forever** - rule 11, with more at stake. `files/anisette/mac/identity.json`
and `store/` belong together: the library hashes the host UUID into the machine id, Apple binds
the provisioning to the device id, and the Find My token only works alongside Anisette from the
machine that obtained it. Apple will provision a device id **once** (a second attempt answers
HTTP 433 / -29002), so a lost store means a new machine and a new device-list entry.
- **Never leave `adi.pb` in the store.** Provisioning writes the legacy whole-machine blob beside
the per-user one, and while it exists the library says "not provisioned". A process that
provisioned and then asked for headers sent Apple an empty machine id, which surfaces as
**`ec -80009`, "MID is invalid"** - and looked like an emulation fault until a restart, which
never reloads the file, made it go away. `CoreAdi::beginSession` drops it before every session.
- **A Mac's device id is not a UUID**, and FindMy.py's CloudKit client parses it as one. The
keychain and CloudKit clients get `icloud_bridge._forCloudKit`'s view of the account instead,
which answers `device_uuid` with the host UUID and forwards everything else. Anything new that
hands the account to CloudKit has to go through it.
- **Never freeze the emulated clock outside a byte-for-byte comparison.** A constant OTP is
accepted for about a minute and then refused.
- **Grand Slam now asks for a second factor with `hsc` 409 and no `au`**, which the pinned
FindMy.py reads as "no 2FA needed". The fix belongs in FindMy.py's `_gsa_authenticate`
(`_normalize_2fa_status`, as malmeloo/FindMy.py#279 has it). Until the pin reaches a commit with
it, `main._treatHsc409AsSecondFactor` fills `au` in by wrapping `_gsa_request`, and it steps aside
by itself once FindMy.py has the function. `test_the_pinned_findmy_does_not_yet` fails on the
pin move, which is the cue to delete the wrapper.
- **The APK carries GPL-2.0 code** (Unicorn). Keep it in `libmacadi.so`, not in
`libopentagviewer.so`, and keep its obligations met: `assets/licences/unicorn/` carries the
licence files and a notice naming the exact source (`UnicornNoticeTest` keeps it in step with
`MacAdi.cmake`), and `build-release.yml` attaches that source tarball to every release.
- **The C++ port is checked against os-findmy-e's Rust byte for byte**, offline:
`scripts/adi-emulator-diff/compare-libmacadi.sh`, in `macadi-host.yml`. Change `vm.cpp`,
`core_adi.cpp` or `macho.cpp` and that job is what says whether the port still matches.

The emulator suite's sign-in tests cannot cover any of this - they swap the Anisette source for a
fake. What does: the JVM tests (`EmulatedMacAnisetteTest`, `MacIdentityTest`,
`MacProvisioningTest` against `FakeGrandSlam`, `RestoringASessionOnTheMachineThatSignedItInTest`,
the fetcher's), `macadi-host.yml` (the library loads in the ported emulator, and matches adi-core),
`TheEmulatedMacInItsOwnProcessTest` on the device, the Python tests in `test_emulated_mac.py`, and a
real sign-in on a phone.

---

## Building and testing
Expand Down
38 changes: 37 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,40 @@ static constructor running. No provisioning and nothing sent to Apple beyond dow
libraries. See [`app/src/test/adi-on-bionic/README.md`](app/src/test/adi-on-bionic/README.md) for how
it works and what it measured.

### The emulated Mac, on the host

```bash
python scripts/locate_mac_coreadi.py --remote --out /tmp/CoreADI.x86_64
cmake -S app/src/test/macadi-host -B build/macadi && cmake --build build/macadi --parallel
build/macadi/macadi_cli /tmp/CoreADI.x86_64 979a0 /nonexistent 00112233445566778899aabbccddeeff -1 --load-only
```

Since 2026-10-12 Apple gives the Find My token only to a sign-in whose Anisette machine was
minted by a Mac or an iPhone, so the app runs Apple's **macOS** CoreADI under an x86_64 emulator
(`app/src/main/cpp/macadi/`, see `EmulatedMacAnisette`). This builds the same native core for the
machine you are on, through the same `MacAdi.cmake`, and loads Apple's library into it. The first
line downloads 3.5 MB from Apple with exactly the range request the app makes and checks it
against the pinned hashes; `--dmg iTunes12.8.3.dmg` does the same against a full copy of the
installer and shows which of its chunks the range spans.

Given a provisioned store directory and its host UUID instead of `--load-only`, `macadi_cli`
prints one header set. Pass a fixed Unix time as the fifth argument to compare its output byte
for byte against another implementation; never use a fixed clock for anything sent to Apple.

#### Against os-findmy-e's Rust, byte for byte

```bash
scripts/adi-emulator-diff/compare-libmacadi.sh /tmp/CoreADI.x86_64 [provisioned store dir]
```

Builds os-findmy-e's `adi-core` (pinned commit) and this port's `macadi_diff`, has both make the same
offline CoreADI calls with a frozen clock and a fixed host UUID, and diffs the output; it exits
non-zero on any difference. Needs git, cargo, cmake and libclang (`unicorn-engine-sys` runs bindgen).
`macadi-host.yml` runs it on every change to the port. Without a store, the calls stop short of the
OTP path; a **copy** of a provisioned store (`files/anisette/mac/store/` from an install, through
`adb shell run-as`) takes both drivers through one header session on that machine as well, still
offline.

### Which Python each tree targets

There are three, and they are not the same:
Expand Down Expand Up @@ -863,7 +897,8 @@ scripts/adi-emulator-diff/run.sh /tmp/CoreADI.x86_64
```

Runs Apple's macOS CoreADI under Unicorn and under icicle with the same inputs and diffs the
output; it exits non-zero on any difference. Unlike the scripts above it does need the network,
output; it exits non-zero on any difference. (`compare-libmacadi.sh`, above, uses the same driver
to compare this repository's C++ port with the Rust it came from.) Unlike the scripts above it does need the network,
to clone three repositories at pinned commits, but it sends nothing to Apple. Nobody needs to run
it routinely; it exists for whoever wants to replace Unicorn. Why, what it showed and what it
does not cover: [docs/emulated-mac-emulator-choice.md](docs/emulated-mac-emulator-choice.md).
Expand Down Expand Up @@ -898,6 +933,7 @@ run regardless: each AES entry carries a fresh random salt.
| `native-stubs.yml` | `app/src/main/cpp/**`, `app/src/test/cpp/**` changes | Builds the stand-ins for Apple's two stubbed libraries on x86_64 and arm64 Linux and checks what they export and return. Seconds, no Android |
| `adi-on-bionic.yml` | `app/src/main/cpp/**`, `adi-libraries.json`, `AdiFunction.java`, its own directory | Loads and initialises Apple's real ADI libraries on x86_64 and arm64 Linux under Android 9 and Android 14 bionic, with no emulator, and checks the pre-#232 stub still fails. About a minute cold, seconds warm |
| `check-gsa-edge.yml` | daily, and every PR to `main` | Asks Apple's edge whether it still lets the app's and the exporter's sign-in and provisioning requests through, with their exact headers and no account. A scheduled failure opens an issue. Seconds |
| `macadi-host.yml` | `app/src/main/cpp/macadi/**`, `app/src/test/macadi-host/**`, `MacCoreAdi*.java`, `scripts/locate_mac_coreadi.py`, `scripts/adi-emulator-diff/**` | Fetches Apple's macOS CoreADI with the app's own range request, checks both pinned hashes, builds the emulated Mac's native core for the host and has it load the library; a second job runs `compare-libmacadi.sh`, the port against os-findmy-e's `adi-core` byte for byte. Nothing sent to Apple beyond the download. A few minutes each, mostly building Unicorn |
| `waiting-on-reporter.yml` | a comment on an issue, daily | Removes the `waiting on reporter` label when the issue's author replies. Daily, comments once on any labelled issue quiet for 14 days, and again only after somebody else has commented. Never closes anything. Both triggers run the copy on `main`, so a PR cannot exercise it; `workflow_dispatch` defaults to a dry run that prints what it would post |

The instrumented job needs KVM on the runner; the workflow enables it first. It runs the same
Expand Down
25 changes: 25 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,28 @@ What is derived, and where it lives:

Apache-2.0 terms continue to apply to the derived portion above. AirGuard
ships no NOTICE file of its own, so there is none to propagate here.

--------------------------------------------------------------------------------

Unicorn (https://github.com/unicorn-engine/unicorn), version 2.1.4
Licensed under the GNU General Public License, version 2

Linked into libmacadi.so, the emulated Mac that signs in to Find My, which the
APK carries and runs in a process of its own (":macadi"), reached from the rest of
the app over binder. Nothing else in the APK contains it. Unicorn is fetched from
source at build time, pinned by SHA-256 in app/src/main/cpp/macadi/MacAdi.cmake.

The APK carries Unicorn's licence files and a notice saying where the
corresponding source is, under assets/licences/unicorn/. Each release that ships
libmacadi.so has the exact Unicorn source tarball attached beside the APK.

--------------------------------------------------------------------------------

sacca97/os-findmy-e (https://github.com/sacca97/os-findmy-e)
Copyright (c) 2026 sacca
Licensed under the MIT License

What is derived, and where it lives: app/src/main/cpp/macadi/{macho,vm,core_adi}.*
are a C++ port of its native/adi-core, and the provisioning requests and machine
identity in anisette/MacProvisioning.java and anisette/MacIdentity.java follow its
tools/machoadi. The full licence text is in app/src/main/cpp/macadi/NOTICE.md.
Loading