NexusIRL is a free, open-source IRL live streaming app for Android: SRTLA bonding of mobile data and Wi-Fi, adaptive bitrate, SRT and RTMP, Twitch and Kick chat, OBS remote control. An Android port of Moblin.
97e26d2, 2026-10-18).upstream/ submodule.Media/HaishinKit/Srt/SrtSender.swift, 619 LOC) is pure Swift.Media/Srtla/Client, ~1.1k), RTMP/RTMPS with AMF0 and enhanced-RTMP HEVC (HaishinKit/Rtmp, ~2.2k), the MPEG-TS writer and reader (HaishinKit/Mpeg, ~3.3k) and adaptive bitrate (ABR) (Media/AdaptiveBitrate, ~800).AudioRecord at 48 kHz is enough.VideoUnit.swift, 1126 LOC).
counter/fps + start, nudged ±1 frame when it drifts more than 30 ms from the host clock.VideoEffects, rendered once per frame into a pooled buffer. That buffer fans out to the encoder(s), preview, recorder, remote-control JPEG and snapshots.
ModelScene.swift sceneUpdatedOn):
x, y, size as a % of the canvas, measured from an alignment corner (VideoEffects/EffectUtils.swift).Database in Various/Settings/Settings.swift).
.moblinSettings zip containing settings.json plus media folders.moblin://?<json> using the MoblinSettingsUrl schema.RemoteControl/RemoteControl.swift, Moblink/MoblinkProtocol.swift). The wire format must match exactly — see §4.tests/, driven by mediamtx and ffmpeg) controls the app over the remote-control WebSocket. It has device capability flags in tests/utils/config.py.
MoblinTests/ can be ported as golden tests, e.g. MpegTsSuite, AmfSuite, RtmpSuite, SrtSenderSuite, SrtSuite, AdaptiveBitrateSuite, RemoteControlSuite, MoblinkSuite, SettingsSuite and TwitchChatSuite.| Old spec | Replace with | Why |
|---|---|---|
| FFmpeg + librtmp for RTMP | Kotlin port of Moblin’s RTMP client | Moblin does not use FFmpeg. It avoids FFmpeg’s LGPL/GPL obligations and binary size. Enhanced-RTMP HEVC and Opus/FLV come for free. |
| libsrt via NDK in v1 | Kotlin port of SrtSender (default) |
Moblin’s default sender is in-repo Swift, and its pacing, retransmit and drop logic is what is tuned for IRL. libsrt is only needed for the post-v1 ingest server. |
| Oboe + C++ mixer, <10 ms audio | AudioRecord (48 kHz, getTimestamp(TIMEBASE_MONOTONIC)) |
There is no mixer upstream, and audio latency is irrelevant next to the transport buffer. |
| Sub-100 ms SRT latency | SRT latency setting of 500–3000 ms (default 3000) | That is Moblin’s default (defaultSrtLatency). Sub-100 ms glass-to-glass over bonded cellular is not achievable. |
KEY_PRIORITY=1 for real-time |
KEY_PRIORITY=0 (0 = real-time), KEY_LATENCY=1, KEY_OPERATING_RATE |
The old values were wrong. |
| Thermal headroom drives ABR | ABR stays network-driven (SrtFight/Belabox). Thermal is a separate policy (status, fps/resolution step-down) | This matches upstream semantics. The thermal policy is an Android-only addition. |
Single app module, com.moblin.* |
Multi-module Gradle, own namespace | Pure-JVM modules let the ported tests run on the host. Using “moblin” as the package invites trademark confusion. |
| 80% coverage target | Ported golden suites plus the upstream Python system harness | Behavioural parity is the real bar. |
SurfaceView for the preview and Compose for the UI are kept from the old spec.
Platform: Kotlin 2.x, Jetpack Compose, minSdk 29, target latest.
AudioPlaybackCapture, thermal-status listeners and a sane Camera2.Gradle modules (all pure JVM unless marked Android):
:upstream git submodule eerimoq/moblin pinned (reference, Web/ assets, tests/ harness)
:core:codable Swift-Codable-compatible JSON for kotlinx.serialization (see §4)
:core:clock host clock (System.nanoTime), PTS helpers, WrappingTimestamp
:media:mux AMF0, FLV tags (incl. enhanced RTMP), MPEG-TS writer/reader, NAL/SEI utils, ADTS, OpusHead
:transport:rtmp RTMP client (handshake, chunking, publish, auth retries, RtmpStreamInfo stats)
:transport:srt SrtSender port (handshake v5 + streamid ext, pacing, NAK retransmit, drops)
:transport:srtla SRTLA client (reg1/2/3, keepalive, congestion window, scoring, batching)
:transport:abr AdaptiveBitrate base + SrtFight (fast/slow/custom) + Belabox
:net (Android) MultiNetworkManager, WebSocketClient, HttpClient, HttpServer, NsdManager
:settings Database schema subset, import/export zip, deep-link parser, migrations
:media:pipeline (Android) Camera2, AudioRecord, GL compositor, frame timer, MediaCodec, MediaMuxer
:effects (Android) GL effect passes + bitmap-backed widgets
:remotecontrol, :moblink, :platforms:{twitch,kick,youtube}, :emotes, :obs
:app (Android) Model controller, Compose UI, StreamingService (foreground service)
iOS → Android mapping:
| iOS | Android |
|---|---|
processorPipelineQueue |
Single HandlerThread “pipeline” (+ its own EGL thread for the GL render) |
Per-transport DispatchQueues |
One single-thread executor or coroutine dispatcher each |
| AVCaptureMultiCamSession, NV12 | Camera2 → SurfaceTexture (OES) into the GL compositor; concurrent cameras post-v1 |
CVPixelBufferPool + CoreImage/MetalPetal |
EGL offscreen FBO chain (GLES 3.x). Text, image and chat widgets are drawn with Canvas into a Bitmap, then uploaded as a texture |
VTCompressionSession |
MediaCodec surface input; eglPresentationTimeANDROID(pts). Live bitrate via setParameters(PARAMETER_KEY_VIDEO_BITRATE) |
| ABR DataRateLimits (1.2×) | VBR + KEY_MAX_BITRATE where supported; CBR option (behaviour varies by device — test matrix) |
AVAudioConverter AAC/Opus |
MediaCodec AAC-LC / Opus, re-chunked into 1024/960-sample frames (AudioEncoderRingBuffer port) |
NWConnection.requiredInterface |
ConnectivityManager.requestNetwork(TRANSPORT_CELLULAR/WIFI/ETHERNET) + Network.bindSocket(DatagramSocket); keep the callbacks registered so cellular stays up while on Wi-Fi |
AVAssetWriter (fragmented MP4) |
MediaMuxer MP4 in v1; fragmented MP4 for crash-safety/replay post-v1 |
| UIBackgroundModes audio/voip | Foreground service of type camera\|microphone\|connectedDevice plus an ongoing notification (also replaces the Live Activity) |
UserDefaults file + Keychain |
JSON file in filesDir (same schema, secrets replaced by references); all secrets in SecretStore (Keystore + Tink, §9.3) |
SwiftUI ObservableObject sub-models |
AppController singleton plus many small StateFlow holders (mirror Bitrate, Battery, StatusTopRight… to keep recomposition cheap) |
Timestamp rule: all PTS are in the System.nanoTime() (MONOTONIC) domain.
SENSOR_TIMESTAMP: check SENSOR_INFO_TIMESTAMP_SOURCE. If it is REALTIME, subtract the (elapsedRealtimeNanos − nanoTime) offset.AudioRecord.getTimestamp.DriftTracker, BufferedVideo and BufferedAudio unchanged when the ingests arrive.:core:codable) — build first, with golden fixturesIt must reproduce Swift JSONEncoder defaults:
{"case":{"label":v}}; payload-less cases as {"case":{}}. Use sealed classes plus a custom serializer.Date is a Double of seconds since 2001-01-01.Data is base64.[k,v,k,v] array (e.g. filters).explicitNulls=false).ignoreUnknownKeys and default values everywhere (mirrors container.decode(key, Type, default) in Common/Various/CommonUtils.swift).Remote control (RemoteControl/RemoteControl.swift, RemoteControlStreamer.swift, RemoteControlAssistant.swift, RemoteControlRelay.swift, RemoteControlWeb.swift):
hello{apiVersion:"0.1",authentication{challenge,salt}} → identify{streamerId,authentication} → identified{result}.
b64(SHA256(b64(SHA256(password+salt))+challenge)).ping/pong every 30 s.twitchStart token: AES-GCM combined (nonce‖ct‖tag), key = SHA256(password)./bridge/control/{id}, /bridge/data/{id}/{conn}, /streamer/{id}.Moblin/RemoteControl/Web/ assets unchanged, together with the generated /js/config.mjs and the routes /recordings.json, /recordings/*, /thumbnails/* and /snapshot.Moblink:
_moblink._tcp with TXT name.startTunnel(address,port). The relay opens a UDP listener on Wi-Fi plus a UDP socket bound to cellular, and forwards payloads unchanged.Golden fixtures:
RemoteControlSuite/MoblinkSuite, the web frontend TS types (WebRemoteControlFrontend/) and the Python moblin_assistant client used by tests/utils/moblin.py.Ports (Various/Network/Ports.swift) — keep the same defaults:
| Service | Port |
|---|---|
| Remote control assistant | TCP 2345 |
| Moblink | TCP 7777 |
| RTMP server | TCP 1935 |
| SRT | UDP 4000 |
| SRTLA | UDP 5000 |
| RIST | UDP 6500 |
| WHIP | TCP 8310 |
| HTTP proxy | TCP 8450 |
Progress (2026-10-19):
- Phase 0 is done, including the app icon, the licence check, the signed-release workflow, F-Droid metadata text, the privacy policy and the in-app licences screen. The licences screen uses the licensee report bundled in the APK instead of AboutLibraries, which avoids a runtime dependency. ktlint and Gradle dependency verification run in CI (detekt dropped as redundant for now).
- Phase 1 is done. Its gate was met with ffmpeg/libsrt as the reference receivers instead of mediamtx:
- The CLI streams over RTMP, SRT and SRTLA (via the ported SRTLA relay).
- Recordings decode strictly.
- ABR reacts correctly under a shaped bottleneck.
- SRT AES encryption is included.
- Phase 2 is in progress. Verified on the emulator:
- capture → encode → SRT/SRTLA/RTMP at 720p30
- SRTLA bonding over cellular + Wi-Fi, with failover
- automatic reconnect
- encoder probe (hardware preferred; HEVC offered only with a hardware encoder, otherwise H.264)
- thermal status with a critical warning
- microphone selection with auto-switch to external mics
On a real phone (see “Real-device testing” below): 15-minute soak with A/V sync and thermal checks done. The 60-minute soak was done on 2026-10-26 and bonding over a real mobile network (an iPhone as Moblink relay) in the field test on 2026-11-02. Still to do: more OEMs.
- Phase 3 is in progress. Done:
- the settings document (iOS-compatible; unknown keys preserved)
- saved streams with stream keys in the encrypted store
- streams list and stream editor
.moblinSettingsimport and export (without keys by default)moblin://setup links with a confirmation dialogcamera preview while not live, zoom (presets and pinch), torch, tap-to-focus and auto focus, camera switching
recording to MP4 in
Movies/NexusIRL, alone or while live, also in the backgroundsnapshots (JPEG of the current frame to
Pictures/NexusIRL)manual camera controls: exposure bias, focus, shutter, ISO, white balance, stabilization
- translations: 33 of 88 strings at first; as of 2026-11-05, 1 to 122 of 416 translatable strings per language (most languages over 110) in 20 languages, from upstream’s catalog where the English text matches (
tools/import-translations.py), with a per-app language picker on Android 13+; the rest falls back to EnglishEverything in Phase 3 that the emulator can check is done.
- Phase 4 is in progress. Done:
- scenes and widgets in settings (iOS-compatible, unmodelled widget types kept)
- a GL compositor (camera + widgets drawn once per frame, then copied to the encoders, preview and snapshots)
- text widgets (upstream format placeholders, timers, stopwatches), image and QR code widgets
scene picker and scene/widget editors
global effects: gray scale, sepia, pixellate, 4:3, CRT, whirlpool, pinch (GLSL, upstream order and parameters)
browser widgets (WebView on a private virtual display rendering straight into the compositor; https only; the
moblinobject only with Moblin access; messages were logged until Phase 5, which delivers chat throughmoblin.subscribe)the “Blur” scene switch transition while a new camera starts
LUTs: the two bundled upstream LUTs plus imported .cube files and LUT images, applied first
quick buttons: upstream’s list and order (iOS round trip), effects, torch, mute, record, snapshot, stealth mode and local overlays; which are shown and their order is configurable
the “Freeze” and “Blur & zoom” transitions, and showing the transition on every scene switch
location (upstream totals, privacy regions) for speed, altitude and distance in text widgets
- map widget (MapLibre, OpenFreeMap by default, hidden in privacy regions)
Phase 4 is feature complete on the emulator. The universal release APK is about 47 MB (MapLibre’s native libraries for four CPU types). As §8.4 plans, releases now also ship one APK per ABI (
arm64-v8a,armeabi-v7a,x86_64), about a third of that size (.github/workflows/release.yml). Tap-to-focus mapping, pinch zoom and the manual controls (which the emulator camera accepts but ignores for exposure bias and focus distance) were to be checked on a real phone; they are verified on the Galaxy S26 Ultra (see “Camera controls” under “Real-device testing”).Phase 5 progress: anonymous chat read for Twitch (IRC), Kick (Pusher) and YouTube (innertube polling, video id from the channel handle), shown over the preview. Channels are set per stream. Chat widgets draw the chat into the stream, and browser widgets with Moblin access get chat through
moblin.subscribe({chat: {}}). BTTV, FFZ and 7TV emotes can be turned on per stream. A chat settings screen sets the overlay’s appearance. Twitch sign-in (Device Code flow) enables sending chat, EventSub alerts and viewer counts. Twitch alert toasts (upstreammakeToast) and the per-stream chat and toast alert settings (twitchChatAlerts,twitchToastAlerts, iOS-compatible) are done; the alert widget with images and sounds stays post-v1 with the other alert effects. Twitch title and category (read, category search, update after a confirmation) are done. Kick sign-in (2026-10-29), as upstream: Kick’s website in a WebView in its own process (:kick, own data directory, deleted after sign-in), thesession_tokencookie kept in the secret store; sending chat, chat bot answers, title and category. Kick’s official OAuth needs a client secret, which an open-source app cannot keep. Kick alerts (subscriptions, gifts, rewards, hosts, bans, Kicks;kickChatAlerts/kickToastAlerts, iOS-compatible) are done. Still to do: YouTube sign-in.Phase 6 progress: remote control, streamer side: an iOS Moblin assistant (or the web remote through a compatible assistant) sees status and a preview and controls scenes, going live, recording, zoom, torch, mute, stealth mode and effects. Checked on the emulator against a protocol-level test assistant. Moblink relay: an Android phone lends its mobile data to a Moblin streamer on the same Wi-Fi (found by mDNS or entered manually), running in the background. Checked on the emulator against a protocol-level test streamer, with UDP passed through the relay both ways. Assistant: an Android phone controls another phone running Moblin or NexusIRL on the same Wi-Fi, with a lockout after wrong passwords. Checked on the emulator against a protocol-level test streamer. Web remote control: upstream’s browser frontend served by the app on port 8080, behind an access token link. Checked in a real browser on the emulator. Moblink streamer: other phones on the Wi-Fi add their mobile data as SRTLA paths while live. Checked with our relay in tests (UDP through the tunnel) and on the emulator (mDNS, startTunnel). Self-hosted relay (§8.2):
server/relay(pinned upstream relay, Caddy for TLS) anddocs/remote-control.md. Checked locally: the emulator’s streamer, through the relay container, shown in the relay’s browser assistant. Phase 6 is feature complete on the emulator. iOS interop checked on 2026-10-25 with Moblin on an iPhone SE (iOS 27): its assistant controlled NexusIRL (status, preview, zoom, mute, going live and ending), and its settings export imported with streams, scenes and widgets. Still to do: Moblink with an iOS streamer (needs mobile data on the relay phone).Real-device testing (Galaxy S26 Ultra, Snapdragon SM8850, Android 17), always to receivers on a desktop, never to a streaming platform:
- Found and fixed: a crash on first start (camera opened before the permission was granted), a black picture (black filler frames stamped “now” made every camera frame look old), the picture upside down in landscape (display rotation applied the wrong way), content under the navigation bar, the 0.6x ultra-wide zoom preset missing, empty mic and bitrate menus in the web remote control, a crash when ending an RTMP stream (a late socket event on the stopped executor), and RTMP viewers getting no picture for the first 2 s in most starts (audio set the timeline base, so the first key frame, carrying its earlier camera capture time, was dropped).
- 1080p60 H.265 for 5:30: 59.9 fps average, no gaps, battery 33 -> 37 C (thermal “light”).
- 1080p30 H.265 5 Mbps over RTMP for 15:42: every frame arrived, no gaps, audio/video offset moved less than 13 ms, battery 30 -> 34 C, thermal state normal. Hardware encoders
c2.qti.avc.encoderandc2.qti.hevc.encoder; the camera runs at 60 fps for 60 fps streams.- SRTLA over Wi-Fi to the desktop receiver: clean, RTT about 5 ms.
- Recording (MP4, H.265 + AAC) and snapshots; scenes with a text widget; the web remote control from a desktop browser.
- Orientation: upright in both landscape directions, also when turned 180 degrees while open (that needs a display listener: Android sends no configuration change for it); portrait streams (1080x1920) upright; the Portrait quick button gives a portrait UI with a landscape stream rendered as if held in landscape, like upstream.
- Camera controls: exposure bias, manual focus and long-press autofocus reach the camera; tap-to-focus lands on the tapped point (mapped into the 16:9 crop of the 4:3 sensor).
- 2026-10-25: live on Twitch for 5:29 (RTMPS, 1080p30 H.264 5 Mbps,
c2.qti.avc.encoder), 1-2 s delay, chat, a follow alert, title and category update and restore. Found and fixed: remote control previews dropped a stream frame every second.- Huawei P30 Pro (Kirin 980, Android 10 = minSdk, EMUI 11): the published v0.1.1 APK, permission prompts, a
moblin://link import and 73 s of 1080p30 H.265 onOMX.hisi.video.encoder.hevcwith every frame (largest gap 34 ms).- 2026-10-26, 60-minute soak (Phase 2 gate, over Wi-Fi to the test receiver, 1080p30 H.265 5 Mbps, SRTLA): 3,617 s at 30.03 fps average, no reconnects, no gaps over 100 ms in the recording, audio/video offset 21.6 ms after 10 minutes and 33.2 ms after 60 (no drift). While charging the battery reached 44.9 C and Android’s thermal status “severe” from minute 27 without dropped frames.
- 2026-10-26: Moblin’s system-test harness runs against NexusIRL (
tools/moblin-harness): settings import, scene switching and RTMP streaming pass; it found and fixed settings key mismatches, status formats, request ordering and a web remote control crash. B-frames in recordings (v0.3.0) and 60 fps timestamp regularity (2026-10-29: codecs started and released off the GL thread) are fixed; the record suite passes in all six formats. Open: the SRT and SRTLA suites (they need the computer’s firewall opened for Python and MediaMTX).- 2026-10-30/31 on the Galaxy S26 Ultra: Kick sign-in (session in the secret store, sign-in page’s cookies verified deleted), Twitch and Kick chat sending,
!moblin helpand!moblin stream titleanswered on both, Kick title and category read. A camera taken by the Samsung Camera app now comes back by itself (before: frozen until switched by hand).- 2026-11-01 and 2026-11-02, field tests (docs/testing/field-test.md), streaming while driving: on 2026-11-01 the video sent 7-14 Mbps for a 5 Mbps stream; fixed (the video now stays within 1.2 times its bitrate in any second), and on 2026-11-02 it stayed at 5 Mbps with 16 late packets in 14 minutes, against 1,896 in 8 minutes before. On 2026-11-02 Moblink bonding with an iPhone as relay worked over its mobile data.
- Still to do on a phone: Moblink between two Android phones and with an iOS streamer.
Each phase ends with an exit gate. Each Kotlin file carries a header comment naming its upstream Swift source (// Port of Moblin/Media/Srtla/Client/SrtlaClient.swift @97e26d2). A PORTING.md table maps Swift files to Kotlin files so upstream changes can be tracked later.
E:\github\moblin-a with git init.upstream/, pinned to 97e26d2.docs/PORT_PLAN.md and mark the original spec as superseded (now docs/archive/moblin-a-spec.md)../gradlew test lint assembleDebug.NOTICE with the Moblin MIT text.:core:codable plus its golden tests, and :core:clock.Gate: codable golden tests pass.
:media:mux, :transport:*)Port the following, keeping all constants verbatim:
hvc1 and Opus FourCC.SrtSender:
0x00010503/flags 0xBF, streamid ext type 5 with word byte-reversal0x9000…0x9212; the reg2→NGP→reg1→reg2→reg3 group flowwindow/(inFlight+1) × priority phase-in between 10k–20k; batching flushes at >15 packets or 15 msmax(t+1M, 1.7t); tick every 20 ms (Belabox) or 200 ms (others).Port the matching MoblinTests suites as JUnit tests. Add a JVM CLI (tools/push) that streams an ffmpeg-generated H.264/AAC elementary stream.
Gate:
ffprobe shows clean timestamps.tests/utils/lossy_udp_relay.py.:media:pipeline, :net)makePresentationTimeStamp drift rule, black-frame and gap-filler behaviour, and the monotonic-PTS drop.AudioRecord with mic selection (built-in, USB and BT via setPreferredDevice), gain, mute, level meter (peak dBFS over 50 ms windows every 200 ms) and AAC/Opus encoding.MultiNetworkManager exposes per-transport Network objects to SRTLA; it replaces NWPathMonitor.StreamingService foreground service, a wake lock, and a thermal listener for status.Gate:
:settings, :app)Database subset needed for v1:
SettingsStream video/audio/SRT/RTMP/ABR fields and connectionPriorities)show, mics, remote control, Moblink, chat.moblinSettings zip in both directions, clipboard JSON, and a moblin:// intent filter handled by the MoblinSettingsUrl parser.
recordingPath bookmarks, SF Symbols → Material icon map.MainView):
SettingsShow flag), and a paged quick-button control bar with the stream button.Common/Localizable.xcstrings (2093 keys, 26 locales) → values-xx/strings.xml.Gate: importing a real iOS export reproduces its streams, scenes and quick buttons, and all three stream protocols can be started from the UI.
:effects)EffectUtils layout math and the effect-ordering rules.moblin.js bridge with the same JS API (publish, subscribe(chat|speechToText), message) so existing overlays keep working.cube), grayscale, sepia, pixellate, 4:3, CRT, whirlpool and pinch as GLSL passes.Gate: the upstream harness suites scenes.py and browser_widget.py pass (after Phase 6).
:platforms:*, :emotes, :obs)KickPusher.swift); login via WebView with the session_token cookie as Bearer; v1/v2 APIs.get_live_chat polling (≥200 ms) with no auth needed; AppAuth-Android with your own Google OAuth client for the Data API.Gate: the harness chat.py suite passes (it includes the Twitch EventSub mock), and live chat works on all three platforms.
:remotecontrol, :moblink)relayId priorities.Gate:
Capability flags to tests/config.toml, then pass stream, settings, scenes, mic, record, web_remote_control, chat and macros (as implemented).just test-stability --duration 1 (1 h).test-stability soak on 3 reference devices: Pixel (Tensor), Samsung S-series (Snapdragon) and a mid-range Exynos/MediaTek.BufferedVideo/BufferedAudio/DriftTracker:
RtmpServer/*), RTSP client.srtlaPatches option lives in eerimoq/SrtSwift’s libsrt fork).0x55,len,0x04,crc8 (init 0xEE, poly 0x31) + crc16 (init 0x496C, poly 0x1021); pair PIN “mbln”.WatchProtocol.swift).Presentation), Android TV/USB UVC cameras.audio_video_sync.py.NET_CAPABILITY_NOT_RESTRICTED handling, tested per OEM.upstream/ with PORTING.md to schedule re-syncs../gradlew test runs the ported MoblinTests golden suites (mux, RTMP, SRT, SRTLA, ABR, codable, settings, remote control, Moblink, chat parsers) on the JVM.mediamtx (config at upstream/tests/utils/mediamtx.yml). Validate with ffprobe -show_packets (monotonic PTS/DTS, PCR cadence) and introduce loss with upstream/tests/utils/lossy_udp_relay.py../gradlew connectedCheck covers the pipeline (frame timer cadence, encoder config, multi-network binding).upstream/tests, just test --device <android>) through the Android app’s wire-compatible remote-control server.just test-stability.tests/utils/audio_video_sync.py and timecodes..moblinSettings file imports on Android.mys-lang.org, moblin.app, api.moblin.app, moblinremote.com, eerimoq.github.io. The only exceptions are attribution text in About/NOTICE and the user-selectable relay preset (§8.2).Requirement (from you): a public, free, open-source GitHub project that anyone can download and run, and that works with zero dependency on services operated by eerimoq or by us. Every network service is either:
The inventory comes from grepping the upstream source for author-owned hosts, plus the integration deep-dive.
| Upstream dependency | Owner | Used for | Android port decision |
|---|---|---|---|
wss://moblin.mys-lang.org/moblin-remote-control-relay (+ assistant.html) |
eerimoq (server is MIT, Go: eerimoq/moblin-remote-control-relay) |
NAT traversal between remote-control streamer and assistant | Default off / empty URL. See §8.2. Ship a Dockerfile + deploy guide (server/relay/) built from the MIT relay. The relay URL is a user setting. |
api.moblin.app (ModelMoblinWebsite.swift: /streamers/live, challenge) |
eerimoq (moblin_website, no licence) |
Listing live streamers on moblin.app; uses iOS App Attest, so it is unusable from Android anyway | Drop. No equivalent. |
Moblin Cloud (ModelMoblinCloud.swift, moblin.app/dashboard: tips, wheel of luck, song requests) |
eerimoq (closed) | Viewer interaction dashboard | Drop in v1. It is only a RemoteControlStreamer pointed at a URL. Keep the generic “connect to custom remote-control server” ability, so a future open-source dashboard can plug in. |
Twitch OAuth: client ID qv6bnocuwapqigeqjoamfhif0cv2xn, redirect https://mys-lang.org/auth |
eerimoq’s Twitch app | Helix, EventSub, chat send | Register the project’s own Twitch app. Use the Device Code Grant flow (public client: no secret, no redirect URI, no web server). The user enters a code at twitch.tv/activate. The client ID is public and committed, and forks can override it in gradle.properties. Fallback: WebView with a http://localhost redirect intercepted in-app (the same pattern as upstream’s WebView fallback), which also needs no server. |
YouTube/Google OAuth client ID 863735387781-… |
eerimoq’s Google project | Data API: broadcasts, viewer count | Own Google Cloud project with an Android OAuth client, which is bound to the package name + signing SHA-1. Forks and self-builds need their own client, so allow a client-ID override in settings. Chat needs no OAuth (innertube polling), so YouTube chat works for everyone. Make OAuth features optional. The youtube scope needs Google app verification (privacy policy + homepage on GitHub Pages) before more than 100 users can use it. |
| Kick | Kick | Chat (Pusher public key), API via WebView session_token cookie |
Keep. No author dependency. Optionally add Kick’s official OAuth later. |
moblin.app links (chat-bot docs, wizard, go-live notification text, About), discord.moblin.app, eerimoq.github.io/.../privacy-policy |
eerimoq | Help and docs links | Point to our GitHub repo/Pages docs and our privacy policy. Credit Moblin and eerimoq on the About screen with a link to the upstream repo (attribution only). |
StoreKit in-app purchases (alternate icons, Store) |
Apple | Monetization | Drop. The app is free; an optional donation link in README only. |
decapi.me (Twitch avatars) |
Third party | Avatars | Replace with Helix users → profile_image_url (already authenticated). |
raw.githubusercontent.com/id3adeye/kickicons |
Third party | Kick badge images | Bundle in-app if the licence allows, otherwise draw simple vector badges. No runtime fetch. |
| BTTV / FFZ / 7TV | Community | Emotes | Keep, with per-provider toggles (upstream already has them). |
pool.ntp.org (TrueTime) |
Public pool | Timecodes only | Keep as a user-editable default. |
| Map widget (Apple MapKit) | Apple | Map overlay | MapLibre with a user-configurable style URL. Default to a free no-key provider (e.g. OpenFreeMap). No Google Maps API key in the repo. |
| Weather (WeatherKit, post-v1) | Apple | Text widget | Open-Meteo (free, no key, open source). |
| Mobcam OBS plugin | eerimoq (GPL-2.0) | PC side of Mobcam | Implement only the wire protocol from MIT Moblin code. Never copy mobcam source. |
The remote control works with no cloud at all, in four tiers (documented in-app and in docs/remote-control.md):
ws://<ip>:2345. The built-in web remote control is served by the Android app itself (HTTP 80/81, NSD _http._tcp), so any browser on the LAN can control it with no server.server/relay/ holds the MIT Go relay, vendored or submoduled, with its licence. It includes a Dockerfile, a docker-compose.yml with Caddy for automatic TLS, and one-click guides (Fly.io/Render free tier, any VPS). Users paste their relay URL into settings.The wire protocol stays identical to upstream, so the Android app also interoperates with iOS Moblin and with eerimoq/moblin_assistant (Python, MIT) on any of these paths.
eerimoq/Moblink (MIT): an existing Android Kotlin Moblink relay app. Reuse or adapt its relay and network-binding code for Phase 6 instead of writing it from scratch. This also gives a ready interop peer for testing.eerimoq/moblin-remote-control-relay (MIT, Go): the self-host relay for §8.2.eerimoq/moblin_assistant (MIT, Python) and eerimoq/systest_moblin (MIT): used by the system-test harness and pulled in through the upstream/tests requirements.eerimoq/srt (MPL-2.0 fork with the SRTLA patches), eerimoq/librist (BSD-2) and eerimoq/libdatachannel (MPL-2.0): the source forks for the post-v1 NDK builds.LICENSE holds our copyright; NOTICE reproduces Moblin’s MIT notice and lists bundled third-party licences.About → Open source licences screen is generated with the AboutLibraries Gradle plugin.srtla (AGPL) and mobcam (GPL) may be used as external test tools only, never linked.licensee plugin).LocationManager, not FusedLocation.gms product flavour, never the default build.local.properties.arm64-v8a, armeabi-v7a and x86_64 APKs plus a universal one.fastlane/metadata/android/...).applicationId, e.g. io.github.<you>.<name>. “Moblin-A” is a working title, and using “Moblin” in the name needs eerimoq’s OK (§8.5).Open a GitHub Discussion or comment on issue #149 announcing the port and asking about:
None of the plan depends on the answers. The defaults above assume “no”.
remoteControl.relay URL as-is, but does not enable it automatically. Also drop the moblinCloud, iconImage and StoreKit fields.eerimoq/Moblink. Add server/relay/ (Docker + Caddy + guide) and docs/remote-control.md (the LAN, Tailscale and self-host tiers).fdroid build) succeeds. Done 2026-10-26 (fdroid/,
docs/RELEASING.md): scanner clean on source and APK, build succeeds on F-Droid’s JDK 21 image.streamid and passphrase query, and WHIP bearer headers. They sit in settings (Library/SimpleStorage/settings), and only Twitch/Kick/YouTube tokens go to the Keychain (Various/Keychain.swift). The .moblinSettings export contains every key in cleartext.getSettings returns only names and IDs, so keys don’t leak through it.)importSettings can overwrite all settings, including stream URLs, from a remote assistant.SrtSender (our v1 port target) has no SRT encryption. Passphrases work only via the libsrt “Official” path (SrtSocketOption.swift).ws:// on LAN, so it can be brute-forced offline if sniffed.Secret everywhere)streamid/passphrase, RIST secret), RTMP auth user/password, WHIP/WHEP auth headers, multi-stream destination URLs.session_token cookie.:core:secrets, Phase 0)SecretStore design:
EncryptedSharedPreferences.rtmp://live.twitch.tv/app/{secret:UUID}. The real value is injected only in memory when the stream starts.SecretStore and then scrubs the plaintext.android:allowBackup="false" and dataExtractionRules to exclude everything. Keystore keys are device-bound anyway, so a restored backup would be unreadable.setUserAuthenticationRequired + BiometricPrompt to reveal or edit a stream key. Streaming never needs the prompt.KeyboardType.Password / visualTransformation masking with a reveal eye. The IME gets IME_FLAG_NO_PERSONALIZED_LEARNING and autocorrect off, so keyboards don’t learn keys.FLAG_SECURE on every screen that can show a secret: stream URL/key editors, deep-link/QR creator, settings import preview, OAuth screens. This blocks screenshots, screen recording, casting and recents thumbnails.scheme://host only, never the path or query.ClipDescription.EXTRA_IS_SENSITIVE (API 33+) and clears the clip after 60 s.SrtSender:
network_security_config sets cleartextTrafficPermitted=false for HTTP. Allowed exceptions: user-entered streaming schemes, LAN-only local servers, and localhost for the WebView OAuth fallback.SecretStore./oauth2/revoke, Google revoke).session_token is captured into SecretStore.Applies to the web remote control, the remote-control assistant, Moblink, and the ingest servers.
http://<ip>/?t=<token>)./js/config.mjs, which already sets websocketPort, with the token embedded for the WS URL.wrongPassword (5 attempts, then 60 s back-off per IP)wss:// (self-hosted relay with TLS) or Tailscale over the internetimportSettings, setStream, and whip with a custom URL. They are disabled by default behind a “Allow remote settings import” toggle.moblin:// links, QR scans and imported files are untrusted input:
FLAG_SECURE.Redactor) is applied to every logger sink: logcat, the in-app log, remote-control log events and log export.
scheme://host/***.streamid=, passphrase=, Discord webhook paths) are masked.SecretStore value is masked by exact match.moblin.js bridge exposes only chat and speech-to-text messages, never settings or secrets.allowFileAccess=false and allowContentAccess=false, with no file:// URLs.verification-metadata.xml) and version catalog pinning, with Dependabot/Renovate keeping them current.SECURITY.md with a private vulnerability-reporting channel (GitHub Security Advisories).docs/threat-model.md covers device theft, a hostile LAN, a malicious QR/deep link, a malicious browser-widget page, a sniffed remote-control session and leaked screenshots or streams. Each guardrail above maps to a threat.:core:secrets, Redactor, backup rules, network security config, CI gitleaks/CodeQL, SECURITY.md and the threat model.SrtSender, plus its interop test.SecretStore values: scan the output JSON for each value.docs/threat-model.md.