Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
Tarkk는 Wi‑Fi·블루투스로 근처 사람과 직접 연결하는 무전 앱으로, 인터넷 없이 음성이 서버에 전송되지 않습니다
Tarkk is a walkie-talkie app that connects you directly to people nearby using WiFi or Bluetooth. No internet needed, and your voice never touches a server.
소셜Dart · CGitHub @HPTarkk온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Talk instantly. No cell towers. No internet needed.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Real-time, push-to-talk voice between two or more phones — no internet, no server, no account. Wi-Fi, Bluetooth, or a phone-hosted hotspot carry the audio directly, phone to phone. Built for the field: two riders on motorcycles, handsfree headsets, a shared hotspot link, and nothing else around.
Android ↔ Android, iPhone ↔ iPhone, Android ↔ iPhone.
Website: tarkk.ir · Join from a browser, no install: app.tarkk.ir
Copyright (c) 2026 Tarkk — dual-licensed.README 전문 보기 ↗Real-time, push-to-talk voice between two or more phones — no internet, no server, no account. Wi-Fi, Bluetooth, or a phone-hosted hotspot carry the audio directly, phone to phone. Built for the field: two riders on motorcycles, handsfree headsets, a shared hotspot link, and nothing else around.
Android ↔ Android, iPhone ↔ iPhone, Android ↔ iPhone.
Website: tarkk.ir · Join from a browser, no install: app.tarkk.ir
Copyright (c) 2026 Tarkk — dual-licensed.
Open source: AGPL-3.0. Use, modify, distribute freely under its terms. Commercial: want it in a closed-source product, or don't want AGPL's terms? Email [email protected] for a negotiated license (royalty, revenue-share, subscription, or one-time).
VOICE_COMMUNICATION on Android, .voiceChat on iOS), plus an app-level cleaner: RNNoise (neural, default, Android verified / iOS built but untested), classic spectral subtraction as the universal fallback, or both cascaded. Switchable in Advanced settings.| Feature | Android | iOS |
|---|---|---|
| Wi-Fi (LAN) voice | ✅ | ✅ (unicast; broadcast needs Apple's multicast entitlement) |
| Bluetooth Classic (RFCOMM) | ✅ | ❌ (Apple forbids Classic for apps) |
| Bluetooth LE (GATT) | ✅ | ✅ |
| Wi-Fi Hotspot Bridge — host | ✅ (API 26+) | ❌ (no programmatic hotspot API) |
| Wi-Fi Hotspot Bridge — join | ✅ (in-app, API 29+) | ✅ (auto-join needs Hotspot Configuration, else manual) |
| Music / device-audio cast | ✅ (API 29+) | ❌ (no OS capture API) |
| OS echo-cancel / noise-suppress / AGC | ✅ | ✅ |
| Neural (RNNoise) noise suppression | ✅ default, verified | wired, unbuilt/untested on-device |
| Home-screen widget | ✅ (compact + wide) | ✅ (WidgetKit, iOS 14+) |
| Widget MUTE/END without opening the app | ✅ | ❌ (extensions can't reach a backgrounded app) |
Minimum OS: Android 8.0+ (hotspot host needs 8.0, music cast needs 10.0) / iOS 13+.
You don't have to pick. The main screen asks one thing — starting a channel, or joining one? — and resolves the rest from what your phone can see. Each button names the route before it takes it. Pin one by hand in Settings → Advanced settings if you'd rather skip the ladder.
Scanning a host's code drops you into "join this Wi-Fi yourself"? Usually Wi-Fi is off on your phone (the join screen now offers the toggle inline) or Location is off (needed for Wi-Fi scanning through Android 12).
Scanned the wrong code? There are two, and they look alike: the bridge shows the host's Wi-Fi code, the People sheet shows a room invite. Point the room scanner at a Wi-Fi code and it no longer calls it an expired invite — it follows it, taking you to the join side of the bridge with the code already read, so you don't scan twice. The host's Wi-Fi code carries the channel too, so you land in their conversation and not just on their network.
flutter pub get
# Code generation (required after changing DI annotations or ARB files)
dart run build_runner build # injectable DI
flutter gen-l10n # localizations
dart run flutter_launcher_icons # app icons (first run)
flutter build apk --release # Android
flutter build ios --release # iOS (requires macOS + Xcode)
One script per kind of release. Each does the whole thing — version, build,
Bazaar signing, tag, GitHub release — and ends back on main with the new
version merged in:
.\scripts\release-patch.ps1 # 1.0.21 -> 1.0.22, a new tag on release/1.0.0
.\scripts\release-minor.ps1 # 1.0.21 -> 1.1.0, opens release/1.1.0 from main
.\scripts\release-major.ps1 # 1.4.2 -> 2.0.0, opens release/2.0.0 from main
There is one branch per major.minor line (release/<major>.<minor>.0); a patch
is only ever a tag. pubspec.yaml is the single source of the version, and the
build number always rises. A patch run from main merges main into the line
first; run it with a release/* branch checked out to hotfix that line
without pulling in main. Signing passwords come from android/key.properties
(gitignored), the same file Gradle signs with; scripts/bundlesigner-*.jar
must sit next to the scripts.
Output lands in build/release/<version>/: Tarkk.aab + bin/ for the Bazaar
console and Tarkk.apk for ArvanCloud. Uploading to Bazaar is the one manual
step; once Bazaar has published the build, announce it in website/update.json
(see App updates).
After pulling native changes, in ios/:
pod install
Then in ios/Runner.xcworkspace, under Signing & Capabilities for the Runner target, confirm:
NEHotspotConfiguration for auto-join). Automatic signing usually adds it; otherwise + Capability → Hotspot Configuration. Without it, iOS falls back to a manual "join this Wi-Fi in Settings" flow.group.com.b1101.tark on both Runner and TarkWidgetExtension. This is the only channel the home-screen widget has into app state — miss it (or let the two targets disagree) and the widget shows its "finish setup" placeholder forever.Info.plist already declares the usage strings and UIBackgroundModes (audio, bluetooth-central, bluetooth-peripheral).iOS Wi-Fi note: UDP broadcast needs Apple's restricted multicast entitlement, which this app doesn't have — iOS discovers peers by unicast sweep + Local Network permission instead.
A separate web entrypoint:
flutter build web --release -t lib/main_guest.dart
# deploy build/web to any static HTTPS host; set the URL via
# --dart-define GUEST_APP_URL=https://your-host (see lib/core/config/guest_config.dart)
Deployed at app.tarkk.ir, which is GUEST_APP_URL's default.
The PowerShell build script can publish the Flutter output to its own Cloudflare Worker after the build. Plain execution cleans, builds, publishes and verifies the live web app with no prompts:
./scripts/build-web-release.ps1 # clean, build, publish, verify; no questions
./scripts/build-web-release.ps1 -Deploy # clean, build, publish, verify; no questions
./scripts/build-web-release.ps1 -DryRun # build and validate Wrangler without publishing
./scripts/build-web-release.ps1 -BuildOnly # clean/build without publishing
wrangler.guest.jsonc serves build/web on app.tarkk.ir with SPA routing and
targets its existing Worker, rapid-paper-9072. Override the existing guest
Worker name with -WorkerName or update name in that configuration when the
hosting target changes. Use PowerShell 7 and an installed,
authenticated Wrangler for publishing. Builds need only Flutter and Git.
The script uses the defaults below and restores the caller's CI environment
afterward. Add -NonInteractive:$false to enable interactive options.
This script builds only the Flutter guest web app; APK and landing-site builds
use their separate release scripts. Defaults: https://app.tarkk.ir, base href
/, flutter clean first, and no ZIP. Override the latter options with
-CleanFirst:$false or -MakeZip:$true as needed.
Publishing checks
the public /web-release.json marker against the app version, source commit and
build time. It does not bump the Android version or publish the marketing site.
The marketing Worker's name is rejected as a guest deployment target.
Deployment regressions run in CI with mocked tools and HTTP; run them locally
with pwsh -File scripts/test-build-web-release.ps1. Asset routing follows
Cloudflare's SPA configuration.
website/ is the static marketing site at tarkk.ir — plain HTML/CSS/JS, deployed as-is, in two languages (English at /, Persian at /fa/).
Edit website/index.html for layout — website/fa/index.html is generated from its bilingual attributes. FAQ copy lives in website/content.js; titles, meta tags and social copy live in scripts/website-shared.mjs. The generator also synchronizes the English FAQ/SEO regions, so the visible answers and structured data stay identical. Both languages are complete static documents; JavaScript adds the interactive app demo and motion.
node scripts/build-website-i18n.mjs # regenerate website/fa/index.html
node scripts/build-website-i18n.mjs --check # verify it's current; exits 1 if not
node scripts/build-legal-pages.mjs # regenerate the six legal pages
node scripts/check-website.mjs # localization, SEO, assets and script-free email routes
node scripts/serve-website.mjs # local preview at http://127.0.0.1:4183/fa/
The existing assets-only Cloudflare Worker is configured in wrangler.website.jsonc. Use PowerShell to version, validate and publish it:
./scripts/release-website.ps1 -DryRun # validate the next patch without uploading or saving a version bump
./scripts/release-website.ps1 # bump patch, generate, check, deploy and verify tarkk.ir
./scripts/release-website.ps1 -Bump minor # or major
./scripts/release-website.ps1 -Version 2.0.0 -Message 'Website redesign'
Node.js, Git and an authenticated Wrangler installation are required. website/package.json holds the website's version, independent of the Android version. Each successful release has a website-vX.Y.Z Wrangler version tag and a public /site-version.json marker with the source commit, working-tree state and build time. Commit the updated version files after publishing. A dry run or failed upload restores those files; a published release retains them even if the public verification fails. This script does not create Git tags or releases. It uploads the entire website/ directory, including update.json, legal/, .well-known/, _headers and _redirects. The browser guest app is hosted separately and is not part of this upload.
The forest palette and application handoff are documented in docs/website-design.md. Local font files, icons and GSAP include their licenses. The branded loading sequence runs on the first visit in a tab; subsequent document navigation shares the horizontal curtain used for language changes. Same-page anchors retain smooth scrolling. Email-token fallback routes use CSS-only entrance motion.
Account-email links (https://tarkk.ir/v/<register|reset|email>#<token>) open the app through Android App Links, verified by website/.well-known/assetlinks.json, which lists the release signing key's SHA-256 (keytool -list -v -keystore <release keystore>). Without the app they land on website/v/index.html, served at all three paths by website/_redirects: a static, script-free, noindex page that never reads or sends the fragment.
mic ─▶ anti-alias LPF ─▶ resample to wire rate ─▶ noise suppress (spectral, RNNoise, or both) ─▶ 20 ms frames
─▶ VOX gate (adaptive floor, hangover + pre-roll) ─▶ [+ mixed device audio] ─▶ Opus encode (in-band FEC) ─▶ transport
transport ─▶ Opus decode (per-sender, FEC recovery) ─▶ jitter buffer (adaptive 60–180 ms) ─▶ resample to device rate ─▶ speaker
libopus bindings (opus_dart + opus_flutter), with a same-quality opus_dart-only fallback and a PCM16 fallback below that, so there's always a working codec.Negotiated 24 kHz HD voice. Two capable phones automatically negotiate up to 24 kHz instead of the older fixed 16 kHz, falling back to 16 kHz for any peer that doesn't support it — on by default, no version mismatch, ever. Validated on two real Android devices end to end (negotiation, jitter buffer, and reliability all held across screen-off stretches) before becoming the default for everyone. Toggleable per phone in Settings > Advanced (HD Voice), alongside the same on-by-default negotiation for HD Shared Music (48 kHz stereo).
Transport-agnostic (identical bytes over UDP, RFCOMM, and BLE). All multi-byte integers little-endian. Shown here is the current (widest) shape; older, shorter variants are still decoded for backward compatibility — a build that predates a field simply stops reading it.
| Field | Bytes | Notes |
|---|---|---|
| type | 1 | one byte per message-kind × version, e.g. presence / PCM16 audio / Opus audio |
| device id | 1 + n | length-prefixed, stable per process |
| session epoch | 4 | tells a rejoin apart from a stale packet still in flight |
| channel id | 4 | only present once a channel is named |
| name | 4 + n | length-prefixed UTF-8 display name |
| presence payload | 1 + 1 + k + 1 | isTalking · session role · heard-peer list · audio capability bitmask |
| audio payload | 4 + m | seq (uint32) + Opus packet (or PCM16 samples) |
Every field beyond the header was added by appending, never by changing what came before — a build that predates a field just stops reading and is unaffected. The heard-peer list is how a phone learns its own transmissions are going nowhere (every other signal it has is about receiving). The capability bitmask is what the in-development HD-voice negotiation above rides on.
| Item | Detail |
|---|---|
| Wi-Fi port | UDP 4000 (broadcast + unicast to known peers) |
| Discovery | presence every 2 s; peers expire after 8 s |
| Bluetooth | Classic SPP UUID 00001101-…; BLE service C0DE0001-57A1-4B1E-9A0B-2D6577616B69 |
| BLE framing | length-prefixed + chunked to the negotiated ATT MTU |
Clean architecture + BLoC (Cubit), injectable/get_it DI, go_router. Each feature is api/ + domain/ + data/ + presentation/; cross-feature access only ever goes through a feature's api/ barrel. lib/app/ is the composition root; lib/core/ is the shared kernel. Full breakdown in ARCHITECTURE.md.
lib/
├── app/ — composition root: DI wiring, GoRouter, cold-start routing
├── core/ — theme, l10n (fa/en), shared widgets, settings, sfx,
│ home-widget bridge, and core/audio/ (the shared
│ wire-format contract both audio and transfer key off)
└── feature/
├── audio/ — AudioEngine (mic/speaker via vendored audio_io), noise
│ suppression, resampler, jitter buffer, device-audio capture
├── transfer/ — transports + wire protocol: Wi-Fi UDP, Bluetooth
│ (Classic + BLE), combined WiFi/Hotspot page, WebRTC guest
├── walkie/ — WalkieTalkieCubit + main push-to-talk console
├── landing/ — lobby: identity, transport-mode chip, and the way in
│ (resume · join · new room · all rooms)
├── onboarding/ — first-run journey (one-time, replayable from Settings)
├── settings/ — categorized Settings + Permissions pages
└── splash/ — branded cold-start splash (skippable)
packages/
├── audio_io/ — vendored native audio I/O, patched for call-mode routing
├── rnnoise/ — FFI binding to RNNoise, vendored + built per platform
└── beat_transitions/ — app-agnostic step transitions for onboarding
android/…/kotlin/com/b1101/tark/ — audio session/BT/hotspot handlers, widget provider
ios/Runner/, ios/TarkWidget/ — Swift equivalents + WidgetKit extension
| Permission | Reason |
|---|---|
RECORD_AUDIO | Microphone |
MODIFY_AUDIO_SETTINGS | Call-mode + Bluetooth SCO routing |
INTERNET, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE, CHANGE_WIFI_MULTICAST_STATE | Wi-Fi sockets & broadcast |
CHANGE_WIFI_STATE, NEARBY_WIFI_DEVICES | Hotspot Bridge |
CHANGE_NETWORK_STATE | Joining a hotspot in-app |
ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION (≤ API 32) | Required by BT scan / hotspot on older APIs |
BLUETOOTH_CONNECT, BLUETOOTH_SCAN, BLUETOOTH_ADVERTISE | Bluetooth Classic + BLE |
BLUETOOTH, BLUETOOTH_ADMIN (≤ API 30) | Legacy Bluetooth |
FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PROJECTION | Device-audio (music) cast |
| Notification access (optional, granted via system settings) | Lets stopping music-cast also pause the source app |
Conversations never leave the local link — phone to phone over Wi-Fi, Bluetooth, or a hosted hotspot, no server in the path. Voice, presence and channel content never reach any server, the account server included. The app sends no analytics or usage stats anywhere. Without an account, the only things it fetches are two static files from tarkk.ir — the legal-document manifest and the update feed — and it sends nothing with them. Accounts are optional and exist only for subscriptions (Cafe Bazaar); what the account server keeps is listed in the privacy policy, and accounts can be deleted in the app or via tarkk.ir/delete-account.html. The diagnostic log (connections, audio health, and at a higher log level the screens visited, taps and settings changes) stays on the phone and only leaves it when you export it from Settings → Advanced → Diagnostics.
The privacy policy and the terms have one source: website/legal/*.json.
Both languages live in the same file as {en, fa} pairs at every leaf, so
there is no shape in which one language has a section the other does not.
scripts/build-legal-pages.mjs renders everything else from it:
| Output | What it is |
|---|---|
website/privacy.html, website/terms.html | the English pages |
website/fa/privacy.html, website/fa/terms.html | the Persian pages |
website/legal/index.json | the version manifest the app polls |
assets/legal/*.json | the same bytes, bundled into the APK |
website/delete-account.html, website/fa/delete-account.html | the account-deletion page, from website/legal/delete-account.json — a web page only: not in the manifest, not bundled, never a consent gate |
node scripts/build-legal-pages.mjs # rebuild everything
node scripts/build-legal-pages.mjs --check # CI: verify, write nothing
Never hand-edit any of those outputs — change the JSON and rebuild. CI runs
--check, so a commit that edits the source without rebuilding fails rather
than shipping an app whose policy differs from the published one.
Each document carries two integers:
version — increments on every published change, typo fixes included.minAcceptedVersion — the oldest version still counted as accepted.That second one is the only knob that interrupts anybody. A typo fix raises
version and leaves it alone, so nobody is re-prompted for a missing comma; a
substantive change raises both.
On launch the app checks the documents bundled in the APK against what this
device has accepted — no network, always completes — and gates on the result.
Separately and in the background it asks tarkk.ir/legal/index.json whether
anything newer exists.
The rules, which are pinned by test/feature/legal/presentation/manager/legal_consent_test.dart:
Point a fork at its own copy:
flutter build apk --dart-define=TARK_LEGAL_BASE=https://example.com/legal/
An empty value compiles the check out entirely.
On launch the Android app fetches https://tarkk.ir/update.json — a static file
published with the website — and compares build numbers:
{
"android": {
"latestVersion": "1.0.22",
"latestBuild": 32,
"minimumBuild": 0,
"url": "https://cafebazaar.ir/app/com.b1101.tark",
"notes": { "en": ["..."], "fa": ["..."] }
}
}
minimumBuild → a full-screen required update; the app
goes no further until it is updated.latestBuild → an optional prompt, shown only on the
home and room-list screens (never over a live channel). Not now quiets that
build for three days.The request carries a throwaway ?t= query so no cache along the way can hand
back an old answer, and website/_headers marks the file uncacheable too.
Update on Bazaar opens the Bazaar app, or the listing in a browser without it.
Bump latestBuild only after Bazaar has published the build, or people will be
sent to a store page that still has the old one. Point a fork elsewhere, or
compile the check out, with --dart-define=TARK_UPDATE_FEED=<url or empty>.
The bugs worth chasing here only happen on someone else's phone, mid-ride. adb logcat reaches none of that, so the app keeps its own rotating log.
For users: Settings → Advanced → Diagnostics → Share diagnostic log hands a .tarklog file to the share sheet. It stays on the phone until you send it; Clear the log deletes it. A Max log size slider (20 KB–100 MB, 8 MB default) caps it — the oldest segment is dropped, never the whole thing.
What's in it: session lifecycle, screen on/off, socket events, per-peer send failures, a transport summary line every 15 s. No audio, ever.
Reading one: it's gzip plus a keystream — opaque in a chat thread, not encrypted (the key ships in the app). Decode with:
python3 scripts/decode_tark_log.py tark-log-20260807-181500.tarklog
Format defined in tark_log_format.dart; a cross-language golden test keeps the script and that file honest with each other.
Every feature is unlocked — Bluetooth, Wi-Fi, hotspot, music cast, all of it. Nothing in the app asks you for money.
Development still costs something: test devices for radio bugs that only show up on one specific phone, a domain, hosting for app.tarkk.ir. If Tarkk's useful to you:
Any amount helps, and not donating changes nothing about what you get. A well-reproduced bug report is worth just as much — see ISSUE_TEMPLATE.
See LICENSE.