Join the Agent Deck Discord Server!
Discord에서 Agent Deck 커뮤니티 확인, 357명과 자유 음성·텍스트 채팅 이용
LocaPeer: Nostr 기반 개인 간 안드로이드 위치 공유, 계정·추적 없이 종단암호화 위치·메시지·SOS·지오펜스 제공
LocaPeer: private, peer-to-peer Android location sharing on Nostr. No account, no tracking, end-to-end encrypted locations, messages, SOS, and geofences.
소셜KotlinGitHub @daygle온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A private, peer-to-peer location sharing Android app built on the Nostr protocol. No accounts, no proprietary servers, and no tracking. A Nostr relay routes events between devices, but all location data, messages, and control payloads are end-to-end encrypted before they leave your device - the relay sees only opaque ciphertext for application events.
EXACT or SUBURB precision per contact, and SEND, RECEIVE, SEND_RECEIVE, or NONE for the relationship role independently. Set independent location and message retention windows; old data is automatically purged from your contact's device via encrypted remote purge requests.SEND, RECEIVE, SEND_RECEIVE, or NONE (messaging only). Roles can be changed at any time via in-app requests.READ_RECEIPT (kind 1048), and real-time typing indicators. Long-press a sent message for a delivery-status sheet; long-press any message to delete locally, on the recipient's device, or both (NIP-09).PEER_REMOVED + DELETE_MY_MESSAGES + DELETE_MY_LOCATION), clears all of their data locally, drops them from every circle you share, and severs the location link bidirectionally - in contrast to "Remove Contact" which is unilateral.FLAG_SECURE is set app-wide, blocking screenshots and screen recordings of every screen (maps, private key, profile QR included).Settings are organised into focused sub-screens reached from a category list, ordered roughly by how often each is used (most-used first). The top-level global settings menu lists:
Each contact has its own per-contact settings screen with the same most-used-first ordering: Location (share role, schedule), Location History, Safety & Alerts (SOS, missed updates, proximity alerts), Geofences, Privacy (precision, pause, temporary sharing), Messaging, Retention, and Security (supervised mode).
Each device generates a unique Nostr keypair on first launch. Your public key is your identity - no email, phone number, or account required. The private key is encrypted with an AES key held in the Android Keystore and the ciphertext is persisted via Jetpack DataStore.
Share your Invite QR code or Invite Link (locapeer://invite?data=<base64>) found in the profile header at the top of Settings. When a contact scans it, both devices exchange TRACK_REQUEST / TRACK_ACCEPT events to negotiate initial roles and begin subscribing to each other's encrypted location events.
When sharing is enabled, the app publishes a HEARTBEAT event encrypted individually for each subscriber. Heartbeat frequency is motion-adaptive:
| Motion State | Default Interval |
|---|---|
| Driving / Running | 2 min |
| Cycling | 3 min |
| Walking | 5 min |
| Stationary | 15 min |
| Low Battery (<20%) | 30 min |
| Live View (a contact has the map open) | 5 sec |
| SOS active | 15 sec |
Priority, highest first: SOS, then low battery (which also suppresses Live View), then Live View, then motion state. The motion and low-battery intervals can be changed in Settings; SOS and Live View are fixed.
Motion state is detected by a custom speed-based classifier and corroborated by Android Activity Recognition (sensor-based), so GPS scatter alone can't mislabel a walk as a drive. It is displayed alongside each location ping.
Each contact relationship can be set to one of two precision levels:
| Mode | Behaviour |
|---|---|
EXACT | Full GPS coordinates transmitted |
SUBURB | Coordinates rounded to ~0.01°, obscuring exact position within a ~1.1 km radius |
Schedule rules specify which days of the week (Monday-Sunday bitmask) and which minutes of the day (startMinute-endMinute) sharing is active. Multiple rules are combined with OR logic. An empty rule set means always on. SOS alerts bypass schedules entirely. Rules can be set globally or overridden per contact.
Circles are a client-side grouping of contacts you've already added. The creator defines a name and member list; the circle id, name, member list and creator pubkey travel inside a NIP-44-encrypted ENCRYPTED_DM (kind 44) tagged with each recipient's pubkey, so the rest of the circle materialises locally on every member's device without a separate group-invite handshake. There is no shared group key: every member still receives their own individually encrypted copy. A location pin, image or voice note inside a circle uses the same envelope, but the text body is itself a nested media wire envelope (image bytes Base64-encoded and downscaled before encrypting).
Temporary shares use the same per-peer configuration the regular sharing uses, but add an endsAt epoch second that's enforced by a one-shot WorkManager job (TempShareExpiryWorker). Starting a temporary share on a contact the user has paused (sharingEnabled = false) is a no-op - the pause is the authoritative gate and must not be silently overwritten.
SUPERVISED_REGISTER event to the supervisor's pubkey. The supervisor receives a persistent notification with Accept and Decline actions.isMySupervised = true) and sends SUPERVISED_REGISTER_ACCEPT back. The supervised device is notified and supervised mode becomes active. Until then the request shows as pending and settings stay unlocked, so a supervisor who never answers can't lock the device.SUPERVISED_UNLOCK_REQUEST is sent. The supervisor approves or denies via a real-time notification; the result is reflected on the supervised device within 60 seconds (timeout).| Kind | Name | Purpose |
|---|---|---|
| 44 | ENCRYPTED_DM | NIP-44 encrypted direct message |
| 5 | EVENT_DELETION | NIP-09 plaintext deletion request for a published event |
| 1040 | HEARTBEAT | Encrypted location ping with motion state |
| 1041 | SOS_ALERT | High-priority emergency broadcast with coordinates |
| 1042 | TRACK_REQUEST | Initiate or change a peer tracking relationship |
| 1043 | TRACK_ACCEPT | Accept a tracking request |
| 1044 | PEER_REMOVED | Notify peer of contact removal |
| 1045 | PURGE_REQUEST | Request deletion of old location data on peer device |
| 1046 | MESSAGE_PURGE_REQUEST | Request deletion of old messages on peer device |
| 1047 | DELIVERY_ACK | Confirm a single message was delivered |
| 1048 | READ_RECEIPT | Confirm one or more messages were read |
| 1049 | SUPERVISED_UNLOCK_REQUEST | Supervised device requests settings access |
| 1050 | SUPERVISED_UNLOCK_RESPONSE | Supervisor approves or denies access |
| 1051 | DELETE_MY_MESSAGES | Request peer delete all messages from this sender |
| 1052 | DELETE_MY_LOCATION | Request peer delete all location data from this sender |
| 1053 | TYPING | Real-time ephemeral typing indicator |
| 1054 | TRACK_DECLINE | Decline a tracking request |
| 1055 | SUPERVISED_REGISTER | Supervised device initiates supervisor registration |
| 1056 | SUPERVISED_REGISTER_ACCEPT | Supervisor accepts device registration |
Custom application events (kinds 1040-1060) are tagged with the recipient's public key (p tag) and NIP-44 encrypted so only the intended recipient can decrypt them. The exception is kind 5 (EVENT_DELETION), which follows the standard NIP-09 format: its content is a plaintext deletion reason and it references the target event via an e tag, plus the recipient via a p tag so their subscription receives it.
| Layer | Technology |
|---|---|
| UI | Jetpack Compose + Material 3 |
| Dependency Injection | Hilt |
| Storage | Room (SQLite) + DataStore (Settings & Preferences) |
| Secure Storage | Android Keystore + DataStore (AES/GCM) |
| Networking | WebSockets via OkHttp |
| Cryptography | secp256k1-kmp (BIP-340 Schnorr) + Bouncy Castle (NIP-44 v2 ChaCha20-HMAC-SHA256) |
| Maps | MapLibre Native (OpenStreetMap raster tiles) |
| Location | Google Play services: FusedLocationProviderClient + Activity Recognition |
| Motion Classification | Custom GPS speed engine (MotionMath) fused with Activity Recognition (MotionFusion) |
| Background Work | WorkManager (retention enforcement, missed-heartbeat watchdog, temporary-share expiry) + boot receiver |
| QR Codes | ZXing (generation and scanning via zxing-android-embedded) |
| Localization | AppCompat per-app locales (AppCompatDelegate.setApplicationLocales) |
Requirements
compileSdk 37 support)compileSdk 37, targetSdk 37, minSdk 26 (Android 8.0)git clone https://github.com/daygle/LocaPeer.git
cd LocaPeer
./gradlew assembleDebug
Signed release builds run on CI (.github/workflows/android-release.yml), so the Android Studio
Generate Signed APK wizard is no longer needed. Push a version tag - or run the workflow
manually from the Actions tab:
git tag v1.1.46
git push origin v1.1.46
A tag build derives both versions from the tag, so no file needs editing: v1.1.46 gives
versionName 1.1.46 and versionCode 10146 (major x 10000 + minor x 100 + patch; minor and patch
must stay below 100). It builds both a signed APK (for direct installs) and a signed AAB (for
Google Play), attaches both to a GitHub Release and uploads them as workflow artifacts. The
LocaPeer-release-aab artifact also carries R8's mapping.txt; upload it in Play Console alongside
the bundle so crash traces are deobfuscated. A manual (workflow_dispatch) run uses the values in
gradle.properties and only uploads the artifacts.
Add your signing key to Settings -> Secrets and variables -> Actions - use the same keystore you have been signing with, so the APK signature is unchanged and existing installs keep updating in place:
| Secret | Value |
|---|---|
KEYSTORE_BASE64 | the keystore file, base64-encoded (command below) |
KEYSTORE_PASSWORD | keystore password |
KEY_ALIAS | key alias |
KEY_PASSWORD | key password |
# Linux/macOS; paste the single-line output as KEYSTORE_BASE64
base64 -w0 ~/keystores/locapeer-release.jks
Local builds are unaffected: without those values assembleRelease just produces an unsigned APK,
and the Android Studio wizard still signs with the keystore you select. To sign from the command
line instead, pass the same values as Gradle properties:
./gradlew assembleRelease \
-PlocapeerKeystoreFile=/path/to/release.jks \
-PlocapeerKeystorePassword=*** -PlocapeerKeyAlias=*** -PlocapeerKeyPassword=***
Public relays: wss://relay.daygle.net, wss://nos.lol, wss://relay.damus.io, wss://relay.snort.social
TRACKING_ALERT (kind 1058) events notify you if another user receives a geofence or proximity alert triggered by your movement, ensuring you are aware of how your location data is being monitored.EVENT_DELETION (kind 5) - one event per fan-out copy for circle messages, exactly the e tag the recipient's matcher expects - or both at once. Bulk delete works the same way from the conversation and contacts screens.This project is licensed under the MIT License. See the LICENSE file for details.
| 1057 |
SUPERVISED_REGISTER_DECLINE |
| Supervisor declines device registration |
| 1058 | TRACKING_ALERT | Notifies peer when someone receives an alert about them |
| 1059 | CIRCLE_LEAVE | Notify circle owner of member departure |
| 1060 | LIVE_VIEW_REQUEST | Request a short-term location update boost |
OK (NIP-01): if the socket drops before the answer, it is resent on the next connection, and a live send that never got an answer is moved into the queue. A relay that rejects an event as duplicate, invalid, blocked, pow or restricted settles it for good; other rejections (e.g. rate-limited) are retried. Relays that never send OK are covered by a 30-second timeout, after which the event is assumed delivered so the queue can't grow forever. This way an event travelling across two flaky relays doesn't lose a copy just because one was down at sec 0. Queued location heartbeats are capped at 30 per relay and dropped after 6 hours, since newer positions supersede them (SOS alerts are exempt). Ephemeral signals (typing indicators, live-view requests) are never queued, since they are meaningless once the moment has passed.ProcessLifecycleOwner) - not per-Activity stop - controls re-lock, so a transient system permission dialog doesn't sign you out mid-task. No static PIN to compromise; the device's own biometrics are the only keys. On Android 11+ the unlock is bound to a per-use Android Keystore key: the app opens only after that key has actually been used, so a spoofed "success" callback isn't enough. Android 8–10 can't bind a device PIN to a key, so there the system prompt's result is trusted.FLAG_SECURE prevents screenshots and screen recordings across the whole app, and the private-key reveal screen warns the user to treat the key like a password.