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[..