Whistle

org.getwhistle.whistle
by Whistle

Decentralized group location sharing over Nostr + MLS

Whistle is an open-source, decentralized group location sharing app. No accounts, no servers, no plaintext data on relays — groups are end-to-end encrypted with MLS (RFC 9420) over Nostr via the Marmot Protocol.

First release: Aug 3, 2026, 17 total releases.

Most recent release: Sep 23, 2026.

Website Repo

Appears in 2 app stacks.

500 sats / 1 zaps received in the past year.

Sats Received

Underlying data available via MCP: app_zaps, app_releases.

Zap Count

Underlying data available via MCP: app_zaps, app_releases.

Releases

  • Sep 23, 2026 1.11.2
    ## What's Changed * Add Android screenshots to Zapstore listing by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/253 * Clean up ROADMAP.md Deferred section by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/254 * chore: add 3 more Zapstore listing screenshots by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/256 * docs: plan MDK 2.0 / MarmotKit migration by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/257 * v1.11.2: buffer relay-delivered MLS commits by created_at before dispatch by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/255 **Full Changelog**: https://github.com/sjmcnamara/whistle/compare/v1.11.1...v1.11.2
  • Sep 21, 2026 1.11.1
    ## What's Changed * Burn review: three named camps + hide rename pencil for non-admins by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/250 * Merge auto-committed proposals locally before publishing by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/251 * v1.11.1: burn review UI polish + auto-committed proposal merge fix by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/252 **Full Changelog**: https://github.com/sjmcnamara/whistle/compare/v1.11.0...v1.11.1
  • Sep 19, 2026 1.10.6
    ## What's Changed * v1.10.6: re-hide self-promote swipe action on own row (Android) by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/247 **Full Changelog**: https://github.com/sjmcnamara/whistle/compare/v1.10.5...v1.10.6
  • Sep 19, 2026 1.10.5
    ## What's Changed * v1.10.5: re-hide self-promote swipe action on own row by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/246 **Full Changelog**: https://github.com/sjmcnamara/whistle/compare/v1.10.4...v1.10.5
  • Sep 17, 2026 1.10.0
    ## What's Changed * v1.10.0: real self-remove for "Leave Group" (iOS & Android) by @sjmcnamara in https://github.com/sjmcnamara/whistle/pull/239 **Full Changelog**: https://github.com/sjmcnamara/whistle/compare/v1.9.1...v1.10.0
  • Sep 14, 2026 1.9.1
    ### Fixed - **(iOS) No provable story for surviving a background kill or device reboot — unlike Android's v1.8.16 fix, this had never been verified or even instrumented.** `Info.plist` declared `UIBackgroundModes: [location, fetch]`, but `fetch` was dead weight — background fetch needs an `AppDelegate` to receive `application(_:performFetchWithCompletionHandler:)`, and this app had none at all (pure SwiftUI App lifecycle). The `location` mode does work — `LocationService` correctly sets `allowsBackgroundLocationUpdates` and runs `startMonitoringSignificantLocationChanges()`, which is what lets iOS relaunch a terminated app (including after a reboot) when a new location event fires — but nothing in the codebase ever checked *why* a launch happened, so this was an unverified assumption riding on CoreLocation's documented behavior plus an emergent side effect of SwiftUI always instantiating the view tree on any process launch. Added a minimal `AppDelegate` (`@UIApplicationDelegateAdaptor`) whose only job is checking `launchOptions[.location]` and logging it explicitly, so a location/reboot-triggered relaunch is now provable rather than assumed. Removed the dead `fetch` background mode from `Info.plist`/`project.yml` since nothing has ever implemented it. The actual startup sequence (`AppViewModel.performFullStartup()`) is unchanged — it already runs on every launch via the root view's `.task`, headless or not; this fix adds visibility, not a new code path.
  • Sep 13, 2026 1.9.0
    ### Added - **(iOS) Per-group location-sharing pause.** Previously the only pause control was the global "Pause Sharing" switch in Settings, which stopped CoreLocation entirely — all-or-nothing across every group. Group Detail now has its own "Pause Sharing to This Group" toggle (`AppSettings.pausedGroupIds`) that skips just that group's outbound broadcast in `AppViewModel.broadcastLocation`; you keep receiving and viewing everyone else's location in that group as normal, and the global switch still overrides every group at once when it's on. A paused group shows a "Paused" badge in the group list so it's not silently indistinguishable from an actively-sharing one. - **(iOS) Diagnostics' last-event timestamp is now per-group.** `secondsSinceLastGroupEvent` was a single device-wide value in `DiagnosticsReport.Volatile` — with multiple groups it could only reflect whichever one updated most recently, hiding a different group silently stalling out. Moved into each `GroupSnapshot` as `secondsSinceLastEvent`, computed from that specific group's own MDK `lastMessageAt`. Bumped `DiagnosticsReport.schemaVersion` to 2 for the shape change. ### Changed - **(iOS) Nickname-less members now show an abbreviated npub instead of a raw hex prefix.** `NicknameStore`'s fallback (used whenever no nickname is set) previously showed 8 raw hex characters — meaningless to compare against anything. It now bech32-encodes the pubkey and abbreviates it the same way `IdentityCardView` already does (`npub1abc...xyz`), reusing the existing `NostrIdentity.shortNpub` format. This matters most for the two places a nickname-less pubkey reaches the UI before someone has joined a group — the pending-join-request row and the admin's join-approval banner — since it's now the same string the joiner can read aloud from their own Identity card, giving an actual out-of-band way to check "is this really who I think it is" instead of eight characters with nothing to match them against.
  • Sep 11, 2026 1.8.16
    ### Fixed - **(Android) No foreground `Service` existed at all, so the whole app process — not just the Activity — could be killed by the OS within minutes of backgrounding, and nothing ran after a reboot until the app was manually reopened.** Reported live by a GrapheneOS user: "the app halts within minutes of closing and does not run on startup... you can never see anyone's position unless they have the app open." `FOREGROUND_SERVICE`/`FOREGROUND_SERVICE_LOCATION` were declared in the manifest but nothing ever used them — `RelayService`/`MarmotService`/`LocationService` were ordinary Hilt singletons with no lifetime independent of the app process. Added `WhistleForegroundService`, a thin shell whose only job is calling `startForeground()` so the process gets foreground priority instead of being reclaimed like any other backgrounded app; it owns no business logic itself, since the existing singletons keep working on their own once the process survives. Started/stopped by `AppViewModel` to match whether there's actually an active group to share with (no persistent notification for an empty account), and by a new `BootCompletedReceiver` after a reboot. A `BackgroundSessionCoordinator` handles the one real gap a foreground-Service-only fix wouldn't close: nothing previously ran the relay-connect/MLS-init/subscribe sequence outside of `AppViewModel.onAppear()`, so a headless start (boot, or an OS-triggered restart of a killed process) would have had a foreground-priority process doing nothing at all. Declared and started as `FOREGROUND_SERVICE_TYPE_DATA_SYNC`, not `location` — verified live on an emulator reboot cycle that Android throws `SecurityException` the instant a location/camera/microphone-typed FGS calls `startForeground()` from a `BroadcastReceiver`, even inside `BOOT_COMPLETED`'s own temporary background-start allowlist; `dataSync` describes the actual job (syncing relay/MLS/location state) without hitting that restriction, and the same reboot test after switching confirmed the boot receiver, the foreground start, and a real headless relay connection all succeed end-to-end. - **(Android) `ensureSubscriptionsActive()` could false-positive on a dead relay connection.** It only checked whether the subscription coroutine was still `isActive` — not whether the relay it depended on was actually connected. Doze or a network drop can silently kill the socket without the coroutine ever throwing (it sits suspended inside the SDK), so a dead connection with a live-looking job passed this check and never restarted. Now also calls `RelayService.hasConnectedRelays()`, which re-reads real socket status rather than trusting a stale snapshot, and restarts subscriptions if either check fails. - **(Android) `ACCESS_BACKGROUND_LOCATION` was declared in the manifest but never requested at runtime.** `MapScreen` only asked for fine/coarse location; without the background grant, location updates can silently degrade to foreground-only regardless of whether the OS ever touches the process. Now requested as a separate follow-up permission once foreground location is granted (Android disallows batching the two together from API 30+).
  • Sep 3, 2026 1.8.15
    ### Fixed - **(Android) Ordinary in-app navigation could silently kill the whole app's relay subscriptions and location updates, with no recovery until a real backgrounding cycle.** Found live during a 3-Android-device sync test: `RootScreen` correctly obtains one Activity-scoped `AppViewModel` via `hiltViewModel()` and passes it down through the nav graph, but `GroupListScreen` and `GroupDetailScreen` each independently called `hiltViewModel()` for their own copy — since both render inside `NavHost` routes, that resolved to a separate `NavBackStackEntry`-scoped instance. `AppViewModel.onCleared()` calls `marmotService.stopSubscriptions()`/`locationService.stopUpdating()`, and since `MarmotService`/`LocationService` are Hilt `@Singleton`s shared app-wide, tearing down *any* of these throwaway instances (e.g. navigating back out of Group Detail, or tapping the Groups tab while already on it — both pop a backstack entry) killed subscriptions globally, uncorrelated with real backgrounding and invisible to `MainActivity.onResume()`'s recovery path. Fixed by having both screens receive the Activity-scoped instance as a parameter instead of re-fetching their own. - **(iOS & Android) `catchUpGroup()`/`fetchMissedGiftWraps()` could silently no-op after a real lock/Doze cycle killed the underlying relay socket**, exactly when the v1.8.14 catch-up sweep needed to work. `reconnectRelaysIfNeeded()` skipped reconnecting whenever `connectionState`/`connectedRelayUrls` — snapshots from whenever they were last refreshed — still read "connected," even though the actual socket had died in the background with no observer ever updating that snapshot. The subsequent one-shot relay fetch then queried a dead connection, which fails fast with an empty result instead of an error, making the catch-up sweep look like it ran and found nothing when it had never actually asked the relay. `reconnectRelaysIfNeeded()` now calls `refreshConnectedRelays()` for a live read before checking, on both platforms. Verified live: before the fix, a locked device's catch-up fetch returned in 18ms with 0 events; after, it correctly detected 0/3 relays connected, reconnected, and recovered a genuinely-missed 501-event backlog including every message sent while it was locked.
  • Sep 1, 2026 1.8.12
    ### Fixed - **(Android) Admins never saw pending join requests after backgrounding the app.** Reproduced live on a Samsung device: create a group, switch away (screen lock, or jumping to system Settings to grant a permission), switch back — the admin's relay subscriptions never resumed, so any join request sent while backgrounded (or after) was silently dropped, with no error shown. Root cause was two-fold: (1) an Android `Activity` can be destroyed and recreated while merely backgrounded, clearing its `ViewModelStore` and firing `AppViewModel.onCleared()` → `MarmotService.stopSubscriptions()`, with nothing to restart them afterward; (2) `AppViewModel.onAppear()`'s "no identity yet" bailout permanently latched its one-shot startup guard instead of allowing a retry — iOS's equivalent (`performFullStartup()`) already resets that guard in the same branch, a platform-parity gap introduced when this code was ported. Fixed both: `MainActivity.onResume()` now calls a new `MarmotService.ensureSubscriptionsActive()` that restarts subscriptions if found inactive, and the startup guard now resets to allow a retry, matching iOS.
  • Sep 1, 2026 1.8.10
    ### Fixed - **(Android) App crashed immediately on tapping "invite via QR / code."** `InviteShareSheet` badged the QR code's center with `painterResource(R.mipmap.ic_launcher)` — but on this app's minSdk 26+, `R.mipmap.ic_launcher` always resolves to the `<adaptive-icon>` XML in `mipmap-anydpi-v26`, not a drawable, and Compose's `painterResource` throws `IllegalArgumentException("Only VectorDrawables and rasterized asset types are supported")` on anything else. 100% reproducible on every device running the invite flow, caught via `adb logcat` on a GrapheneOS Pixel. Added a dedicated flat badge asset (`drawable/invite_qr_mark.png`, the same 1024×1024 mark iOS already uses via `InviteQRMark` for its own QR badge) and pointed the logo painter at that instead of the launcher mipmap.
  • Aug 28, 2026 1.8.9
    ### Fixed - **(iOS) App Store rejection, Guideline 5.1.1(iv) — Data Collection and Storage: onboarding's pre-permission screen let users bypass the system location prompt entirely.** Apple flagged the "One last thing" screen in `OnboardingView` (shown before requesting location access) on two points: its primary button read "Enable Location" — a directive verb Apple treats as steering the user toward granting access, where the guideline wants neutral wording like "Continue" or "Next" — and a "Skip for now" button let the user dismiss onboarding without the system `CLLocationManager` prompt ever appearing, deferred indefinitely unless they later found the "Authorization" row buried in Settings. Renamed the button to "Continue" and removed "Skip for now": the final onboarding page now always calls `requestAlwaysAuthorization()`, so the user is guaranteed to reach (and remains free to deny) the system dialog, same as any other permission gate. ### Changed - **(iOS) Permission usage-description strings say "group" instead of "family".** `NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, and `NSFaceIDUsageDescription` referred to "family" — leftover wording from before the app's group-oriented rename (matches the "Groups" tab rename in v1.8.8).
  • Aug 19, 2026 1.8.8
    ### Changed - **(iOS) Bottom tab renamed "Chat" → "Groups"; tab icon changed to `person.3.fill`.** The tab's list (`GroupListView`) is a directory of groups — tapping a row does drop straight into that group's chat thread, but the row itself (`GroupRowView`) already shows name, member count, and last-activity time with no message preview, reading as a roster rather than a messaging inbox. The icon moved from `bubble.left.and.bubble.right.fill` (which implies text messaging) to `person.3.fill` so it agrees with the new label instead of contradicting it. Android needed no change here — its bottom nav and group list were already labeled "Groups" throughout. - **(iOS & Android) Removed the redundant "Group" title above Group Detail's avatar/member list.** iOS: an empty `navigationTitle` still reserves the nav bar's full height whether or not it has text, so recovering that space meant hiding the bar entirely (`.toolbar(.hidden, for: .navigationBar)`) rather than just blanking the label — replaced with a floating back-chevron button over the hero section, WhatsApp/Instagram-profile style. Android's `TopAppBar` doesn't reserve extra height for a title either way, so blanking it (`title = {}`) is the complete fix there. - **(iOS & Android) Restyled the invite QR code on both platforms**: brand-dark-grey (`#2A3040`, sampled from the app icon's background) dot-style modules with rounded finder-pattern corners and a small center logo badge, instead of a raw black-on-white barcode. iOS via the new `dagronf/QRCode` package (pinned `exactVersion: "9.2.1"`), since CoreImage's built-in QR generator has no pixel/eye-shape styling hooks. Android via the new `qrose` package (`1.1.2`, the same author's Compose-native sibling — a plain Compose `Painter` via `rememberQrCodePainter`, no `Drawable`/`Bitmap` interop needed), replacing `ZXing` (which had no styling hooks and was used nowhere else in the app). Error correction tuned to medium on both — enough redundancy for the small center badge without paying the much higher module count a high level needs; iOS additionally drops to low wherever there's no badge to protect (also benefiting `IdentityCardView`'s own npub QR, which shares the component). - **(iOS) Redesigned the invite-sharing sheet further**: icon-only share/copy buttons (`square.and.arrow.up` / `doc.on.doc`) replacing the two full-width, text-labelled buttons — the previous "Share via AirDrop / Messages…" label named specific apps that the OS share sheet lets a user hide or reorder, so it was frequently just inaccurate — plus the group's name and avatar above the code, and the code itself moved into a white card with a shadow. Android's sheet already said "the person you want to add to the group" (not a family-member framing) and its buttons were already plainly labelled "Share"/"Copy", so neither needed a wording or button-style change here. - **(iOS & Android) Admin's per-joiner approve/deny in Group Detail's "Ready to Join" section now uses filled circular check/cancel icons**, matching each platform's own existing accept/decline pattern instead of a mismatched pair: `checkmark.circle.fill` (green) / `xmark.circle.fill` (red) on iOS, matching `GroupListView`'s pending-welcome section (previously `person.badge.plus` / unfilled `xmark.circle`); `CheckCircle` (green) / `Cancel` (theme error red) on Android, reusing this codebase's own existing green/red convention from `AdvancedSettingsScreen.kt` (previously `PersonAdd` / plain `Close`).
  • Aug 18, 2026 1.8.7
    ### Changed - **(iOS) `PRODUCT_BUNDLE_IDENTIFIER` moved from `org.findmyfam.app` to `org.getwhistle.whistle`.** `org.findmyfam` predates the app's rename to Whistle and was never updated — matches the Android `applicationId` rename in v1.8.4, for the same reason: this is the last point it can move before a real App Store listing makes it permanent. A new bundle ID means a new App ID and a new App Store Connect app record; it is not an update path for the existing `org.findmyfam.app` TestFlight app. Existing TestFlight testers are not migrated forward — they move to the new app via a new invite and lose local state (identity, groups) on the old install, same trade-off Android's rename made. Internal-only identifiers (`KeychainService`'s keychain service string, `MLSService`'s MDK `serviceId`, the logger subsystem) are deliberately left as `org.findmyfam` — they're private storage labels, not exported anywhere, and renaming them adds risk without benefit, mirroring Android leaving its Kotlin package name and `FindMyFamApp` class alone. - **(iOS) Dropped unused NFC tag read/write.** `NFCReadCoordinator`/`NFCWriteCoordinator` had no call sites left anywhere in `Sources/Views` — the "Tap NFC Tag" / "Write to NFC Tag" entry points from v0.7 are gone, leaving only orphaned files plus a lingering `NFCReaderUsageDescription` string and an NFC entitlement nothing used. Removed both coordinator files, the entitlement, the Info.plist/`project.yml` usage string, and two stray UI mentions of NFC (`JoinGroupView` footer, a `GroupListViewModel` doc comment). Closes the ROADMAP Deferred item that flagged this as a parity candidate to drop.
  • Aug 12, 2026 1.8.6
    ### Fixed - **(iOS) A member of two or more groups saw their own location pinned twice on the "All Groups" map, at slightly different coordinates.** `LocationCache` keys entries by `"groupId:pubkeyHex"`, so belonging to two groups produces two separate cache entries for yourself. The map view built one annotation per cache entry with no deduplication, so both showed up as pins. The two entries normally track each other via `broadcastLocation()` writing the same fresh payload into every group, but `LocationCache.update()` had no ordering guard, so an out-of-order relay echo of your own event in one group could leave that group's entry pointing at a stale coordinate — the visible symptom was two pins with slightly different GPS. `LocationViewModel.refresh()` now collapses to the single freshest self entry when showing all groups, and `LocationCache.update()` ignores an incoming payload older than what's already cached for that key.
  • Aug 4, 2026 1.8.5
    ### Fixed - **(iOS & Android) New members didn't see the group avatar until an admin manually resynced them.** The group avatar travels as an ordinary MLS application message rather than group state, so MLS forward secrecy means a newly-added member can never decrypt whatever avatar message was sent before they joined — the only remedy is the admin re-announcing it as a fresh message after each membership change. That re-announce (`rebroadcastGroupAvatarIfDesignated`) was wired to fire only when the admin's own client re-observed its just-published add-commit coming back over the live relay subscription — asynchronous and not guaranteed to arrive or be classified in time. `addMember`, `addMembers`, and `resyncMember` now trigger the re-announce directly at the point the commit is made, instead of depending on that self-echo. - **(iOS & Android) Rejoining via hard resync showed a stale "Inactive" group entry alongside a duplicate "Accept" invitation for the same group.** `resyncMember`'s remove-then-re-add produces a fresh Welcome that never goes through the invite-code path, so it was misclassified as an unsolicited invite from a stranger requiring approval — even though the Welcome's own cryptographic validity already proves it came from a real admin re-adding a known member. Such a Welcome is now auto-accepted as a resume. Separately, the group list didn't re-render when a new pending welcome arrived (only new MDK group state triggered the filter that hides a group with a pending welcome from the main list), so the old inactive row could remain visible until an unrelated event refreshed it; the list now reacts to pending-welcome changes immediately.
  • Aug 3, 2026 1.8.4
    ### Changed - **(Android) `applicationId` moved from `org.findmyfam` to `org.getwhistle.whistle`.** `org.findmyfam` predates the app's rename to Whistle and was never updated. Android treats `applicationId` as the app's permanent identity — Play, F-Droid, and the [Zapstore](https://zapstore.dev) listing being set up all key off it — so this was the last point it could move without becoming a breaking change for a published store listing. Existing sideloaded installs of `whistle.apk` will not receive this build as an update; they must uninstall the old `org.findmyfam` install and install the new one, which resets local app data (the encrypted MLS database) and requires rejoining groups. The Kotlin package name (`org.findmyfam`) and app class (`FindMyFamApp`) are unchanged — only the Gradle `applicationId` moved, not the `namespace`.