diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4ecb208..1aae9f3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -109,6 +109,13 @@ jobs:
NODE_ENV: production
run: ./gradlew assembleRelease --no-daemon
+ # Guards against shipping without the native socket module (as v1.1.0 did, when
+ # .gitignore excluded its sources): its event name only exists in the Kotlin code.
+ - name: Verify MiLink native module is in the APK
+ run: |
+ unzip -p android/app/build/outputs/apk/release/app-release.apk 'classes*.dex' | grep -aq onTvFound \
+ || { echo "::error::MiLink native module is missing from the APK"; exit 1; }
+
- name: Collect APK
run: |
mkdir -p out
@@ -156,6 +163,11 @@ jobs:
working-directory: apps/mobile/ios
run: pod install
+ - name: Verify MiLink native module is linked
+ run: |
+ grep -q "MiLinkModule" "ios/Pods/Target Support Files/Pods-MiRemote/ExpoModulesProvider.swift" \
+ || { echo "::error::MiLink native module is not linked into the iOS app"; exit 1; }
+
# No Apple Developer account: build unsigned. Sideloading tools re-sign the app with the
# user's own (free) Apple ID when installing it.
- name: Build unsigned archive
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
index 8bd9eaf..df28dcf 100644
--- a/apps/mobile/.gitignore
+++ b/apps/mobile/.gitignore
@@ -10,9 +10,14 @@ yarn-error.log*
expo-debug.log
expo-error.log
-# Native — generated by `expo prebuild`, never commit
-android/
-ios/
+# Native — generated by `expo prebuild`, never commit. Anchored to this folder:
+# unanchored, these also matched modules/*/ios and modules/*/android, the local
+# native modules' own source code.
+/android/
+/ios/
+# Gradle output of the local native modules
+modules/*/android/build/
+modules/*/android/.gradle/
# macOS
.DS_Store
diff --git a/apps/mobile/modules/milink/android/build.gradle b/apps/mobile/modules/milink/android/build.gradle
new file mode 100644
index 0000000..c10b330
--- /dev/null
+++ b/apps/mobile/modules/milink/android/build.gradle
@@ -0,0 +1,15 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.milink'
+version = '1.0.0'
+
+android {
+ namespace "expo.modules.milink"
+ defaultConfig {
+ versionCode 1
+ versionName "1.0.0"
+ }
+}
diff --git a/apps/mobile/modules/milink/android/src/main/AndroidManifest.xml b/apps/mobile/modules/milink/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0da2ba1
--- /dev/null
+++ b/apps/mobile/modules/milink/android/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt b/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt
new file mode 100644
index 0000000..e824b3c
--- /dev/null
+++ b/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt
@@ -0,0 +1,239 @@
+package expo.modules.milink
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.Inet4Address
+import java.net.InetSocketAddress
+import java.net.Socket
+
+/**
+ * Raw TCP socket and Mi TV discovery for the MiLink remote protocol.
+ * Framing and key packets live in TypeScript (src/milink); this module only moves bytes.
+ */
+class MiLinkModule : Module() {
+ // All socket and discovery state is touched only on this thread.
+ private val thread = HandlerThread("expo.modules.milink").apply { start() }
+ private val handler = Handler(thread.looper)
+
+ private var socket: Socket? = null
+ private var output: OutputStream? = null
+ private var generation = 0
+
+ private var nsd: NsdManager? = null
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+ private val resolveQueue = ArrayDeque()
+ private var resolving = false
+ private val reported = mutableSetOf()
+
+ override fun definition() = ModuleDefinition {
+ Name("MiLink")
+
+ Events("onSocketState", "onSocketData", "onTvFound")
+
+ Function("connect") { host: String, port: Int ->
+ handler.post { open(host, port) }
+ }
+
+ Function("disconnect") {
+ handler.post { close() }
+ }
+
+ Function("send") { bytes: List ->
+ val data = ByteArray(bytes.size) { bytes[it].toByte() }
+ handler.post {
+ try {
+ output?.write(data)
+ output?.flush()
+ } catch (e: Exception) {
+ fail(generation, e.message ?: "Write failed")
+ }
+ }
+ }
+
+ Function("startDiscovery") {
+ handler.post { startBrowsing() }
+ }
+
+ Function("stopDiscovery") {
+ handler.post { stopBrowsing() }
+ }
+
+ OnDestroy {
+ handler.post {
+ close()
+ stopBrowsing()
+ thread.quitSafely()
+ }
+ }
+ }
+
+ // region Socket
+
+ private fun open(host: String, port: Int) {
+ close()
+ val gen = ++generation
+ emitState("connecting")
+ // Connecting blocks, so do it on its own thread and hop back to `handler` with the result.
+ Thread {
+ val s = Socket()
+ try {
+ s.tcpNoDelay = true
+ s.keepAlive = true
+ s.connect(InetSocketAddress(host, port), 4000)
+ } catch (e: Exception) {
+ try { s.close() } catch (_: Exception) {}
+ handler.post { if (gen == generation) emitState("failed", e.message ?: "Connection failed") }
+ return@Thread
+ }
+ handler.post {
+ if (gen != generation) {
+ try { s.close() } catch (_: Exception) {}
+ return@post
+ }
+ socket = s
+ output = s.getOutputStream()
+ emitState("connected")
+ startReader(gen, s.getInputStream())
+ }
+ }.start()
+ }
+
+ private fun startReader(gen: Int, input: InputStream) {
+ Thread {
+ val buffer = ByteArray(4096)
+ try {
+ while (true) {
+ val n = input.read(buffer)
+ if (n < 0) break
+ if (n > 0) {
+ val bytes = (0 until n).map { buffer[it].toInt() and 0xFF }
+ handler.post { if (gen == generation) sendEvent("onSocketData", mapOf("bytes" to bytes)) }
+ }
+ }
+ handler.post { fail(gen, "TV closed the connection") }
+ } catch (e: Exception) {
+ handler.post { fail(gen, e.message ?: "Connection lost") }
+ }
+ }.start()
+ }
+
+ private fun fail(gen: Int, message: String) {
+ if (gen != generation || socket == null) return
+ teardown()
+ emitState("failed", message)
+ }
+
+ private fun close() {
+ generation++
+ if (socket == null) return
+ teardown()
+ emitState("disconnected")
+ }
+
+ private fun teardown() {
+ try { socket?.close() } catch (_: Exception) {}
+ socket = null
+ output = null
+ }
+
+ private fun emitState(state: String, error: String? = null) {
+ val body = mutableMapOf("state" to state)
+ if (error != null) body["error"] = error
+ sendEvent("onSocketState", body)
+ }
+
+ // endregion
+
+ // region Discovery
+
+ /** Mi TVs advertise `_googlecast._tcp` with a TXT `md=MiTV-…` model. */
+ private fun startBrowsing() {
+ stopBrowsing()
+ reported.clear()
+ val context = appContext.reactContext ?: return
+ val manager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
+ nsd = manager
+ val listener = object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(serviceType: String) {}
+ override fun onDiscoveryStopped(serviceType: String) {}
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {}
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+ handler.post {
+ if (discoveryListener !== this) return@post
+ val name = serviceInfo.serviceName ?: return@post
+ if (reported.contains(name) || resolveQueue.any { it.serviceName == name }) return@post
+ resolveQueue.addLast(serviceInfo)
+ resolveNext()
+ }
+ }
+ }
+ discoveryListener = listener
+ try {
+ manager.discoverServices("_googlecast._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
+ } catch (_: Exception) {
+ discoveryListener = null
+ }
+ }
+
+ private fun stopBrowsing() {
+ val listener = discoveryListener ?: return
+ discoveryListener = null
+ resolveQueue.clear()
+ try { nsd?.stopServiceDiscovery(listener) } catch (_: Exception) {}
+ }
+
+ // NsdManager resolves one service at a time on older Android versions, so resolve serially.
+ @Suppress("DEPRECATION")
+ private fun resolveNext() {
+ if (resolving || discoveryListener == null) return
+ val info = resolveQueue.removeFirstOrNull() ?: return
+ val manager = nsd ?: return
+ resolving = true
+ manager.resolveService(info, object : NsdManager.ResolveListener {
+ override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
+ handler.post {
+ resolving = false
+ resolveNext()
+ }
+ }
+
+ override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
+ handler.post {
+ resolving = false
+ report(serviceInfo)
+ resolveNext()
+ }
+ }
+ })
+ }
+
+ @Suppress("DEPRECATION")
+ private fun report(info: NsdServiceInfo) {
+ val name = info.serviceName ?: return
+ val attrs = info.attributes ?: emptyMap()
+ val model = attrs["md"]?.toString(Charsets.UTF_8) ?: ""
+ val friendly = attrs["fn"]?.toString(Charsets.UTF_8) ?: name
+ if (!model.lowercase().startsWith("mitv") && !name.lowercase().startsWith("mitv")) return
+ // Android 14+ can resolve several addresses; the TV listens on IPv4.
+ val host = if (Build.VERSION.SDK_INT >= 34) {
+ info.hostAddresses.filterIsInstance().firstOrNull()
+ } else {
+ info.host as? Inet4Address
+ } ?: return
+ val address = host.hostAddress ?: return
+ if (!reported.add(name)) return
+ sendEvent("onTvFound", mapOf("id" to name, "name" to friendly, "model" to model, "address" to address))
+ }
+
+ // endregion
+}
diff --git a/apps/mobile/modules/milink/ios/MiLink.podspec b/apps/mobile/modules/milink/ios/MiLink.podspec
new file mode 100644
index 0000000..eaf1a29
--- /dev/null
+++ b/apps/mobile/modules/milink/ios/MiLink.podspec
@@ -0,0 +1,25 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'MiLink'
+ s.version = package['version']
+ s.summary = package['description']
+ s.description = package['description']
+ s.license = package['license']
+ s.author = 'EndLess728'
+ s.homepage = 'https://github.com/EndLess728/MiTV-Remote-Mobile'
+ s.platforms = { :ios => '16.4' }
+ s.swift_version = '5.9'
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.source_files = "**/*.{h,m,swift}"
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+end
diff --git a/apps/mobile/modules/milink/ios/MiLinkModule.swift b/apps/mobile/modules/milink/ios/MiLinkModule.swift
new file mode 100644
index 0000000..500097b
--- /dev/null
+++ b/apps/mobile/modules/milink/ios/MiLinkModule.swift
@@ -0,0 +1,180 @@
+import ExpoModulesCore
+import Network
+
+/// Raw TCP socket and Mi TV discovery for the MiLink remote protocol.
+/// Framing and key packets live in TypeScript (src/milink); this module only moves bytes.
+public class MiLinkModule: Module {
+ private let queue = DispatchQueue(label: "expo.modules.milink")
+ private var connection: NWConnection?
+ private var browser: NWBrowser?
+ private var resolvers: [String: NWConnection] = [:]
+ private var reported: Set = []
+
+ public func definition() -> ModuleDefinition {
+ Name("MiLink")
+
+ Events("onSocketState", "onSocketData", "onTvFound")
+
+ Function("connect") { (host: String, port: Int) in
+ self.queue.async { self.open(host: host, port: port) }
+ }
+
+ Function("disconnect") {
+ self.queue.async { self.close() }
+ }
+
+ Function("send") { (bytes: [Int]) in
+ let data = Data(bytes.map { UInt8(truncatingIfNeeded: $0) })
+ self.queue.async {
+ self.connection?.send(content: data, completion: .contentProcessed { _ in })
+ }
+ }
+
+ Function("startDiscovery") {
+ self.queue.async { self.startBrowsing() }
+ }
+
+ Function("stopDiscovery") {
+ self.queue.async { self.stopBrowsing() }
+ }
+
+ OnDestroy {
+ self.queue.sync {
+ self.close()
+ self.stopBrowsing()
+ }
+ }
+ }
+
+ // MARK: - Socket
+
+ private func open(host: String, port: Int) {
+ close()
+ guard let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { return }
+ let tcp = NWProtocolTCP.Options()
+ tcp.noDelay = true
+ tcp.connectionTimeout = 4
+ tcp.enableKeepalive = true
+ let conn = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: NWParameters(tls: nil, tcp: tcp))
+ connection = conn
+ emitState("connecting")
+ conn.stateUpdateHandler = { [weak self, weak conn] state in
+ guard let self, let conn, conn === self.connection else { return }
+ switch state {
+ case .ready:
+ self.emitState("connected")
+ self.receive(on: conn)
+ case .waiting(let error), .failed(let error):
+ // .waiting covers "no route" and a denied Local Network permission; report it as a
+ // failure and let the JS side decide when to retry.
+ self.connection = nil
+ conn.stateUpdateHandler = nil
+ conn.cancel()
+ self.emitState("failed", error: error.localizedDescription)
+ default:
+ break
+ }
+ }
+ conn.start(queue: queue)
+ }
+
+ private func receive(on conn: NWConnection) {
+ conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self, weak conn] data, _, isComplete, error in
+ guard let self, let conn, conn === self.connection else { return }
+ if let data, !data.isEmpty {
+ self.sendEvent("onSocketData", ["bytes": data.map { Int($0) }])
+ }
+ if isComplete || error != nil {
+ self.connection = nil
+ conn.stateUpdateHandler = nil
+ conn.cancel()
+ self.emitState("failed", error: error?.localizedDescription ?? "TV closed the connection")
+ } else {
+ self.receive(on: conn)
+ }
+ }
+ }
+
+ private func close() {
+ guard let conn = connection else { return }
+ connection = nil
+ conn.stateUpdateHandler = nil
+ conn.cancel()
+ emitState("disconnected")
+ }
+
+ private func emitState(_ state: String, error: String? = nil) {
+ var body: [String: Any] = ["state": state]
+ if let error { body["error"] = error }
+ sendEvent("onSocketState", body)
+ }
+
+ // MARK: - Discovery
+
+ /// Mi TVs advertise `_googlecast._tcp` with a TXT `md=MiTV-…` model. Browse that service,
+ /// then briefly connect to each match to learn its IPv4 address.
+ private func startBrowsing() {
+ stopBrowsing()
+ reported.removeAll()
+ let params = NWParameters()
+ params.includePeerToPeer = false
+ let browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_googlecast._tcp", domain: "local."), using: params)
+ browser.browseResultsChangedHandler = { [weak self] results, _ in
+ self?.handle(results)
+ }
+ browser.start(queue: queue)
+ self.browser = browser
+ }
+
+ private func stopBrowsing() {
+ browser?.cancel()
+ browser = nil
+ resolvers.values.forEach { $0.cancel() }
+ resolvers.removeAll()
+ }
+
+ private func handle(_ results: Set) {
+ for result in results {
+ guard case let .service(name, _, _, _) = result.endpoint else { continue }
+ var model = ""
+ var friendly = name
+ if case let .bonjour(txt) = result.metadata {
+ model = txt["md"] ?? ""
+ friendly = txt["fn"] ?? name
+ }
+ guard model.lowercased().hasPrefix("mitv") || name.lowercased().hasPrefix("mitv") else { continue }
+ guard resolvers[name] == nil, !reported.contains(name) else { continue }
+ resolve(result.endpoint, id: name, name: friendly, model: model)
+ }
+ }
+
+ private func resolve(_ endpoint: NWEndpoint, id: String, name: String, model: String) {
+ let params = NWParameters.tcp
+ if let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
+ ip.version = .v4
+ }
+ let conn = NWConnection(to: endpoint, using: params)
+ resolvers[id] = conn
+ conn.stateUpdateHandler = { [weak self, weak conn] state in
+ guard let self, let conn else { return }
+ switch state {
+ case .ready:
+ if case let .hostPort(host, _)? = conn.currentPath?.remoteEndpoint {
+ var address = "\(host)"
+ if let pct = address.firstIndex(of: "%") { address = String(address[..