From c36d769aee4d56bc1cccbe7d78d24ca5d3b7b034 Mon Sep 17 00:00:00 2001 From: EndLess728 Date: Thu, 15 Oct 2026 07:08:43 +0530 Subject: [PATCH] fix: commit the MiLink native module sources The mobile .gitignore's unanchored android/ and ios/ patterns, meant for the generated native projects, also matched modules/milink/android and modules/milink/ios. The module's Swift and Kotlin code never reached the repo, so CI builds shipped without it and the apps crashed at launch with "Cannot find native module 'MiLink'". Anchors the patterns to the app root, adds the module sources, and makes CI fail if the module isn't linked into the APK or the iOS app. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build.yml | 12 + apps/mobile/.gitignore | 11 +- .../modules/milink/android/build.gradle | 15 ++ .../android/src/main/AndroidManifest.xml | 6 + .../java/expo/modules/milink/MiLinkModule.kt | 239 ++++++++++++++++++ apps/mobile/modules/milink/ios/MiLink.podspec | 25 ++ .../modules/milink/ios/MiLinkModule.swift | 180 +++++++++++++ 7 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/modules/milink/android/build.gradle create mode 100644 apps/mobile/modules/milink/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt create mode 100644 apps/mobile/modules/milink/ios/MiLink.podspec create mode 100644 apps/mobile/modules/milink/ios/MiLinkModule.swift 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[..