diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index 5a43b3d..a59b28d 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -2,13 +2,13 @@ name: Build and Export IPAs on: push: - branches: [ main ] + branches: [ main, 'v*' ] paths: - 'MicRemote/**' - 'MicListener/**' - '.github/workflows/build_ipa.yml' pull_request: - branches: [ main ] + branches: [ main, 'v*' ] paths: - 'MicRemote/**' - 'MicListener/**' @@ -16,6 +16,7 @@ on: workflow_dispatch: + jobs: build-ipas: name: Build IPAs diff --git a/MicListener/MicListener/ContentView.swift b/MicListener/MicListener/ContentView.swift index a0d76de..d3849d4 100644 --- a/MicListener/MicListener/ContentView.swift +++ b/MicListener/MicListener/ContentView.swift @@ -1,6 +1,20 @@ import SwiftUI import AVFoundation +// ============================================================= +// SHARE SHEET WRAPPER (iOS Native UIActivityViewController) +// ============================================================= + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + struct ContentView: View { // ========================================================= @@ -19,6 +33,12 @@ struct ContentView: View { @State private var serverPort = "" @State private var authToken = "" + // ========================================================= + // REMOTE MIC TELEMETRY + // ========================================================= + + @State private var telemetry = RemoteDeviceTelemetry() + // ========================================================= // LISTENING & DIAGNOSTICS // ========================================================= @@ -27,10 +47,18 @@ struct ContentView: View { @State private var audioLevel: Float = 0.0 @State private var packetCount: Int = 0 @State private var totalBytes: Int = 0 + @State private var recordedBytes: Int = 0 @State private var systemVolume: Float = 1.0 @State private var outputRoute: String = "Đang kiểm tra..." @State private var testStatusMessage: String = "" + // ========================================================= + // AUDIO EXPORT SHARE SHEET + // ========================================================= + + @State private var shareURL: URL? = nil + @State private var showShareSheet = false + // LIVE LOGGER @ObservedObject private var logger = AppLogger.shared @@ -42,264 +70,388 @@ struct ContentView: View { // ========================================================= var body: some View { - ScrollView { - VStack(spacing: 16) { - - // ================================================= - // TITLE - // ================================================= - VStack(spacing: 4) { - Text("MIC REMOTE") - .font(.title) - .bold() - - Text("LISTENER (MÁY NGHE)") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.top, 10) - - // ================================================= - // STATUS SUMMARY - // ================================================= - HStack(spacing: 15) { - VStack { - Text("Server") - .font(.caption) - .foregroundColor(.secondary) - Text(isConnected ? "🟢 Đã nối" : (isConnecting ? "🟡 Đang nối" : "🔴 Mất nối")) - .font(.subheadline) + ZStack(alignment: .bottom) { + ScrollView { + VStack(spacing: 16) { + + // ================================================= + // 1. TITLE & SUBTITLE + // ================================================= + VStack(spacing: 4) { + Text("MIC LISTENER") + .font(.title) .bold() - } - .frame(maxWidth: .infinity) - .padding(8) - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - VStack { - Text("iPhone Mic") - .font(.caption) - .foregroundColor(.secondary) - Text(iphoneConnected ? "🟢 Online" : "🔴 Offline") + Text("Made with ❤️ by Binhake ツ") .font(.subheadline) - .bold() + .foregroundColor(.secondary) } - .frame(maxWidth: .infinity) - .padding(8) - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - } + .padding(.top, 10) + + // ================================================= + // 2. STATUS SUMMARY + // ================================================= + HStack(spacing: 15) { + VStack { + Text("Server") + .font(.caption) + .foregroundColor(.secondary) + Text(isConnected ? "🟢 Đã nối" : (isConnecting ? "🟡 Đang nối" : "🔴 Mất nối")) + .font(.subheadline) + .bold() + } + .frame(maxWidth: .infinity) + .padding(8) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) - // ================================================= - // SERVER CONFIG - // ================================================= - VStack(alignment: .leading, spacing: 8) { - Text("Cấu hình Server") - .font(.caption) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - TextField("IP Server", text: $serverIP) - .textFieldStyle(.roundedBorder) - .keyboardType(.numbersAndPunctuation) - .autocorrectionDisabled() - - TextField("Port", text: $serverPort) - .textFieldStyle(.roundedBorder) - .keyboardType(.numberPad) - .frame(width: 75) + VStack { + Text("iPhone Mic") + .font(.caption) + .foregroundColor(.secondary) + Text(iphoneConnected ? "🟢 Online" : "🔴 Offline") + .font(.subheadline) + .bold() + } + .frame(maxWidth: .infinity) + .padding(8) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) } - HStack(spacing: 8) { - TextField("Auth Token", text: $authToken) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + // ================================================= + // 3. REMOTE MIC TELEMETRY CARD (THIẾT BỊ PHÁT) + // ================================================= + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundColor(.blue) + Text("📱 GIÁM SÁT THIẾT BỊ PHÁT (MIC REMOTE)") + .font(.caption) + .bold() + .foregroundColor(.secondary) + Spacer() + } - Button("LƯU") { - saveServer() + // Model & OS + HStack { + Text("Thiết bị:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.model : "--") + .bold() } - .buttonStyle(.borderedProminent) - } - } + .font(.subheadline) - Divider() + HStack { + Text("Hệ điều hành:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.os : "--") + .bold() + } + .font(.subheadline) - // ================================================= - // ACTION BUTTONS (LISTEN / STOP) - // ================================================= - HStack(spacing: 12) { - Button { - startListening() - } label: { + // Pin & Sạc HStack { - Image(systemName: "mic.fill") - Text(isListening ? "ĐANG NGHE" : "BẮT ĐẦU NGHE") + Text("Pin & Trạng thái sạc:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.batteryText : "--") + .bold() } - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 48) - } - .buttonStyle(.borderedProminent) - .tint(.green) - .disabled(!isConnected || !iphoneConnected || isListening) + .font(.subheadline) - Button { - stopListening() - } label: { + // Độ sáng & Âm lượng Mic HStack { - Image(systemName: "stop.fill") - Text("DỪNG") + Text("Độ sáng màn hình:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.brightnessText : "--") + .bold() } - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 48) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .disabled(!isListening) - } + .font(.subheadline) - // ================================================= - // TEST BEEP BUTTON - // ================================================= - VStack(spacing: 6) { - Button { - testStatusMessage = NetworkManager.shared.playTestBeep() - } label: { HStack { - Image(systemName: "speaker.wave.3.fill") - Text("🔊 BẤM ĐỂ TEST LOA (PHÁT TIẾNG BÍP)") + Text("Âm lượng máy phát:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.volumeText : "--") .bold() } .font(.subheadline) - .frame(maxWidth: .infinity, minHeight: 44) + + // Vị trí GPS + HStack { + Text("Tọa độ GPS:") + .foregroundColor(.secondary) + Spacer() + if iphoneConnected, let lat = telemetry.latitude, let lon = telemetry.longitude { + if let mapURL = URL(string: "https://www.google.com/maps?q=\(lat),\(lon)") { + Link(String(format: "%.4f, %.4f ↗", lat, lon), destination: mapURL) + .font(.subheadline) + .bold() + .foregroundColor(.blue) + } else { + Text(String(format: "%.4f, %.4f", lat, lon)) + .bold() + } + } else { + Text("--") + .bold() + } } - .buttonStyle(.bordered) - .tint(.blue) + .font(.subheadline) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) - if !testStatusMessage.isEmpty { - Text(testStatusMessage) + // ================================================= + // 4. DIAGNOSTICS & TRẠNG THÁI MÁY NGHE (VU METER) + // ================================================= + VStack(alignment: .leading, spacing: 10) { + Text("🔍 CHẨN ĐOÁN & TRẠNG THÁI MÁY NGHE") .font(.caption) .bold() - .foregroundColor(testStatusMessage.contains("✅") ? .green : .red) - } - } + .foregroundColor(.secondary) + + // 1. VU Meter (Thanh đo mức âm thanh thời gian thực) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Tín hiệu Mic đang thu (VU Meter):") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.3f", audioLevel)) + .font(.caption) + .monospacedDigit() + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(.tertiarySystemFill)) + .frame(height: 12) + + RoundedRectangle(cornerRadius: 4) + .fill(audioLevelColor) + .frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(min(1.0, audioLevel * 3)))), height: 12) + .animation(.easeOut(duration: 0.1), value: audioLevel) + } + } + .frame(height: 12) + } + + Divider() + + // 2. Âm lượng thiết bị + HStack { + Image(systemName: systemVolume > 0.2 ? "speaker.wave.2.fill" : "speaker.slash.fill") + .foregroundColor(systemVolume > 0.2 ? .primary : .red) + Text("Âm lượng máy nghe:") + Spacer() + Text("\(Int(systemVolume * 100))%") + .bold() + .foregroundColor(systemVolume > 0.2 ? .primary : .red) + } + .font(.subheadline) - // ================================================= - // DIAGNOSTICS & HARDWARE MONITOR - // ================================================= - VStack(alignment: .leading, spacing: 10) { - Text("🔍 CHẨN ĐOÁN & TRẠNG THÁI THIẾT BỊ") - .font(.caption) - .bold() - .foregroundColor(.secondary) - - // 1. Âm lượng thiết bị - HStack { - Image(systemName: systemVolume > 0.2 ? "speaker.wave.2.fill" : "speaker.slash.fill") - .foregroundColor(systemVolume > 0.2 ? .primary : .red) - Text("Âm lượng máy:") - Spacer() - Text("\(Int(systemVolume * 100))%") + if systemVolume < 0.15 { + Text("⚠️ Âm lượng máy đang rất nhỏ! Hãy bấm phím tăng âm lượng trên điện thoại.") + .font(.caption) + .foregroundColor(.red) + } + + // 3. Cổng ra âm thanh + HStack { + Image(systemName: "airpodspro") + Text("Cổng xuất âm thanh:") + Spacer() + Text(outputRoute) + .bold() + } + .font(.subheadline) + + // 4. Gói tin nhận + HStack { + Image(systemName: "waveform.badge.magnifyingglass") + Text("Dữ liệu nhận từ Server:") + Spacer() + Text("\(packetCount) gói (\(formatBytes(totalBytes)))") + .font(.subheadline) + .bold() + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // ================================================= + // 5. DOWNLOAD AUDIO PACKAGE & TEST BEEP + // ================================================= + VStack(spacing: 10) { + Button { + if let url = NetworkManager.shared.exportRecordedWavURL() { + shareURL = url + showShareSheet = true + } + } label: { + HStack { + Image(systemName: "square.and.arrow.down.fill") + Text("Tải gói âm thanh đã nhận (\(NetworkManager.shared.getRecordedSizeString()))") + } + .font(.subheadline) .bold() - .foregroundColor(systemVolume > 0.2 ? .primary : .red) + .frame(maxWidth: .infinity, minHeight: 46) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(recordedBytes == 0) + + VStack(spacing: 4) { + Button { + testStatusMessage = NetworkManager.shared.playTestBeep() + } label: { + HStack { + Image(systemName: "speaker.wave.3.fill") + Text("🔊 BẤM ĐỂ TEST LOA (PHÁT TIẾNG BÍP)") + .bold() + } + .font(.subheadline) + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + .tint(.secondary) + + if !testStatusMessage.isEmpty { + Text(testStatusMessage) + .font(.caption) + .bold() + .foregroundColor(testStatusMessage.contains("✅") ? .green : .red) + } + } } - .font(.subheadline) - if systemVolume < 0.15 { - Text("⚠️ Âm lượng máy đang rất nhỏ! Hãy bấm phím tăng âm lượng trên điện thoại.") + // ================================================= + // 6. SERVER CONFIG + // ================================================= + VStack(alignment: .leading, spacing: 8) { + Text("Cấu hình Server") .font(.caption) - .foregroundColor(.red) - } + .foregroundColor(.secondary) - // 2. Cổng ra âm thanh - HStack { - Image(systemName: "airpodspro") - Text("Cổng xuất âm thanh:") - Spacer() - Text(outputRoute) - .bold() - } - .font(.subheadline) + HStack(spacing: 8) { + TextField("IP Server", text: $serverIP) + .textFieldStyle(.roundedBorder) + .keyboardType(.numbersAndPunctuation) + .autocorrectionDisabled() - // 3. Gói tin nhận - HStack { - Image(systemName: "waveform.badge.magnifyingglass") - Text("Dữ liệu nhận từ Server:") - Spacer() - Text("\(packetCount) gói (\(formatBytes(totalBytes)))") - .font(.subheadline) - .bold() + TextField("Port", text: $serverPort) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .frame(width: 75) + } + + HStack(spacing: 8) { + TextField("Auth Token", text: $authToken) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button("LƯU") { + saveServer() + } + .buttonStyle(.borderedProminent) + } } - // 4. VU Meter (Thanh đo mức âm thanh thời gian thực) - VStack(alignment: .leading, spacing: 4) { + // ================================================= + // 7. LIVE DEBUG LOG CONSOLE (TERMINAL TRÊN APP) + // ================================================= + VStack(alignment: .leading, spacing: 6) { HStack { - Text("Tín hiệu Mic đang thu (VU Meter):") + Text("📋 LIVE DEBUG LOG") .font(.caption) + .bold() .foregroundColor(.secondary) Spacer() - Text(String(format: "%.3f", audioLevel)) - .font(.caption) - .monospacedDigit() + Button("XÓA LOG") { + AppLogger.shared.clear() + } + .font(.caption2) } - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color(.tertiarySystemFill)) - .frame(height: 12) - - RoundedRectangle(cornerRadius: 4) - .fill(audioLevelColor) - .frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(min(1.0, audioLevel * 3)))), height: 12) - .animation(.easeOut(duration: 0.1), value: audioLevel) + ScrollView { + VStack(alignment: .leading, spacing: 3) { + ForEach(logger.logs.indices, id: \.self) { idx in + Text(logger.logs[idx]) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.green) + .frame(maxWidth: .infinity, alignment: .leading) + } } + .padding(6) } - .frame(height: 12) + .frame(height: 120) + .background(Color.black.opacity(0.9)) + .cornerRadius(8) } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // Khoảng trống đệm để tránh bị che bởi thanh nút bấm ghim ở dưới + Spacer(minLength: 85) } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) + .padding(.horizontal) + } - // ================================================= - // LIVE DEBUG LOG CONSOLE (TERMINAL TRÊN APP) - // ================================================= - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("📋 LIVE DEBUG LOG") - .font(.caption) - .bold() - .foregroundColor(.secondary) - Spacer() - Button("XÓA LOG") { - AppLogger.shared.clear() + // ===================================================== + // 8. PINNED BOTTOM ACTION BAR (BẮT ĐẦU NGHE / DỪNG) + // ===================================================== + VStack(spacing: 0) { + Divider() + + HStack(spacing: 12) { + Button { + startListening() + } label: { + HStack { + Image(systemName: "mic.fill") + Text(isListening ? "ĐANG NGHE" : "BẮT ĐẦU NGHE") } - .font(.caption2) + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 48) } + .buttonStyle(.borderedProminent) + .tint(.green) + .disabled(!isConnected || !iphoneConnected || isListening) - ScrollView { - VStack(alignment: .leading, spacing: 3) { - ForEach(logger.logs.indices, id: \.self) { idx in - Text(logger.logs[idx]) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.green) - .frame(maxWidth: .infinity, alignment: .leading) - } + Button { + stopListening() + } label: { + HStack { + Image(systemName: "stop.fill") + Text("DỪNG") } - .padding(6) + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 48) } - .frame(height: 130) - .background(Color.black.opacity(0.9)) - .cornerRadius(8) + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(!isListening) } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - - Spacer(minLength: 20) + .padding(.horizontal) + .padding(.top, 10) + .padding(.bottom, 12) + } + .background(.ultraThinMaterial) + } + .sheet(isPresented: $showShareSheet) { + if let url = shareURL { + ShareSheet(items: [url]) } - .padding(.horizontal) } .onAppear { loadServer() @@ -322,6 +474,11 @@ struct ContentView: View { iphoneConnected = notif.userInfo?["connected"] as? Bool ?? false if !iphoneConnected { isListening = false } } + .onReceive(NotificationCenter.default.publisher(for: .telemetryUpdated)) { notif in + if let newTelemetry = notif.userInfo?["telemetry"] as? RemoteDeviceTelemetry { + telemetry = newTelemetry + } + } .onReceive(NotificationCenter.default.publisher(for: .audioMetricsUpdated)) { notif in if let level = notif.userInfo?["level"] as? Float { audioLevel = level @@ -330,6 +487,9 @@ struct ContentView: View { packetCount += 1 totalBytes += bytes } + if let totalRec = notif.userInfo?["totalRecordedBytes"] as? Int { + recordedBytes = totalRec + } } } @@ -380,3 +540,5 @@ struct ContentView: View { isListening = false } } + + diff --git a/MicListener/MicListener/Info.plist b/MicListener/MicListener/Info.plist index 3a17d78..099d177 100644 --- a/MicListener/MicListener/Info.plist +++ b/MicListener/MicListener/Info.plist @@ -11,9 +11,10 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 1.1.0 CFBundleVersion - 1 + 2 + UIBackgroundModes audio diff --git a/MicListener/MicListener/NetworkManager.swift b/MicListener/MicListener/NetworkManager.swift index 95d2e6d..264aba2 100644 --- a/MicListener/MicListener/NetworkManager.swift +++ b/MicListener/MicListener/NetworkManager.swift @@ -1,6 +1,46 @@ import Foundation import AVFoundation import AudioToolbox +import MediaPlayer + +// ============================================================= +// REMOTE DEVICE TELEMETRY MODEL +// ============================================================= + +struct RemoteDeviceTelemetry { + var model: String = "--" + var os: String = "--" + var deviceName: String = "--" + var batteryLevel: Int = -1 + var batteryState: String = "--" + var brightness: Int = -1 + var volume: Int = -1 + var latitude: Double? + var longitude: Double? + var accuracy: Double? + + var batteryText: String { + guard batteryLevel >= 0 else { return "--" } + let state: String + switch batteryState { + case "charging": state = "⚡ Đang sạc" + case "full": state = "🟢 Đầy pin" + case "unplugged": state = "🔋 Dùng pin" + default: state = "" + } + return state.isEmpty ? "\(batteryLevel)%" : "\(batteryLevel)% (\(state))" + } + + var brightnessText: String { + guard brightness >= 0 else { return "--" } + return "\(brightness)%" + } + + var volumeText: String { + guard volume >= 0 else { return "--" } + return "\(volume)%" + } +} // ============================================================= // APP LOGGER (Hiển thị log trực tiếp lên màn hình app) @@ -46,6 +86,14 @@ final class NetworkManager: NSObject { private var webSocket: URLSessionWebSocketTask? private var session: URLSession? + // ========================================================= + // TELEMETRY & AUDIO RECORDING BUFFER + // ========================================================= + + private(set) var telemetry = RemoteDeviceTelemetry() + private(set) var recordedAudioData = Data() + private(set) var isListeningActive = false + // ========================================================= // DEFAULT SERVER & TOKEN // ========================================================= @@ -103,14 +151,15 @@ final class NetworkManager: NSObject { setupAudioSession() setupInterruptionHandling() + setupRemoteCommands() } func setupAudioSession() { do { let session = AVAudioSession.sharedInstance() - try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setCategory(.playback, mode: .default, options: []) try session.setActive(true) - AppLogger.shared.log("AudioSession kích hoạt (.playback + .mixWithOthers) - Vol: \(Int(session.outputVolume * 100))%") + AppLogger.shared.log("AudioSession kích hoạt (.playback thuần) - Vol: \(Int(session.outputVolume * 100))%") } catch { AppLogger.shared.log("❌ Lỗi AudioSession: \(error.localizedDescription)") } @@ -140,6 +189,7 @@ final class NetworkManager: NSObject { } } + // ========================================================= // GETTERS & SETTERS // ========================================================= @@ -273,6 +323,49 @@ final class NetworkManager: NSObject { manualDisconnect = true } + case "telemetry": + let model = message["model"] as? String ?? "--" + let os = message["os"] as? String ?? "--" + let deviceName = message["deviceName"] as? String ?? "--" + let batteryLevel = message["batteryLevel"] as? Int ?? -1 + let batteryState = message["batteryState"] as? String ?? "--" + let brightness = message["brightness"] as? Int ?? -1 + let volume = message["volume"] as? Int ?? -1 + let latitude = message["latitude"] as? Double + let longitude = message["longitude"] as? Double + let accuracy = message["accuracy"] as? Double + + let newTelemetry = RemoteDeviceTelemetry( + model: model, + os: os, + deviceName: deviceName, + batteryLevel: batteryLevel, + batteryState: batteryState, + brightness: brightness, + volume: volume, + latitude: latitude, + longitude: longitude, + accuracy: accuracy + ) + self.telemetry = newTelemetry + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .telemetryUpdated, + object: nil, + userInfo: ["telemetry": newTelemetry] + ) + } + + case "telemetry_reset": + self.telemetry = RemoteDeviceTelemetry() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .telemetryUpdated, + object: nil, + userInfo: ["telemetry": RemoteDeviceTelemetry()] + ) + } + case "audio_format": if let sampleRate = message["sampleRate"] as? NSNumber { self.audioSampleRate = sampleRate.doubleValue @@ -283,12 +376,22 @@ final class NetworkManager: NSObject { case "status": let iphoneConnected = message["iphoneConnected"] as? Bool ?? false AppLogger.shared.log("Trạng thái Mic: \(iphoneConnected ? "🟢 Online" : "🔴 Offline")") + if !iphoneConnected { + self.telemetry = RemoteDeviceTelemetry() + } DispatchQueue.main.async { NotificationCenter.default.post( name: .iphoneStatusChanged, object: nil, userInfo: ["connected": iphoneConnected] ) + if !iphoneConnected { + NotificationCenter.default.post( + name: .telemetryUpdated, + object: nil, + userInfo: ["telemetry": RemoteDeviceTelemetry()] + ) + } } case "error": @@ -355,6 +458,10 @@ final class NetworkManager: NSObject { private func handleAudio(_ data: Data) { guard !data.isEmpty else { return } + + // Luôn lưu dữ liệu vào buffer hoàn chỉnh để xuất file WAV + recordedAudioData.append(data) + let sampleCount = data.count / 2 guard sampleCount > 0 else { return } @@ -363,6 +470,22 @@ final class NetworkManager: NSObject { AppLogger.shared.log("📥 Đang nhận gói audio #\(totalPacketsHandled) (\(data.count)B)") } + // CHỈ PHÁT RA LOA NẾU THIẾT BỊ NÀY ĐANG Ở CHẾ ĐỘ NGHE + guard isListeningActive else { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .audioMetricsUpdated, + object: nil, + userInfo: [ + "level": Float(0.0), + "bytes": data.count, + "totalRecordedBytes": self.recordedAudioData.count + ] + ) + } + return + } + if audioQueue == nil || !isAudioQueueRunning { setupAudioQueue(sampleRate: audioSampleRate) } @@ -412,7 +535,8 @@ final class NetworkManager: NSObject { object: nil, userInfo: [ "level": rms, - "bytes": data.count + "bytes": data.count, + "totalRecordedBytes": self.recordedAudioData.count ] ) } @@ -427,6 +551,135 @@ final class NetworkManager: NSObject { isAudioQueueRunning = false } + // ========================================================= + // NOW PLAYING & MEDIA PLAYER CONTROLS (DYNAMIC ISLAND / LOCKSCREEN) + // ========================================================= + + func setupRemoteCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget { [weak self] _ in + self?.startListening() + return .success + } + + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget { [weak self] _ in + self?.stopListening() + return .success + } + + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + guard let self = self else { return .commandFailed } + if self.isListeningActive { + self.stopListening() + } else { + self.startListening() + } + return .success + } + + DispatchQueue.main.async { + UIApplication.shared.beginReceivingRemoteControlEvents() + } + } + + func updateNowPlaying(isPlaying: Bool) { + var info = [String: Any]() + info[MPMediaItemPropertyTitle] = "Mic Stream (Live)" + info[MPMediaItemPropertyArtist] = "Mic Remote" + info[MPMediaItemPropertyAlbumTitle] = "Live Audio Broadcast" + info[MPNowPlayingInfoPropertyIsLiveStream] = true + info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0 + + if let img = UIImage(systemName: "mic.fill") { + let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: 200, height: 200)) { _ in img } + info[MPMediaItemPropertyArtwork] = artwork + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + AppLogger.shared.log("NowPlaying: \(isPlaying ? "▶️ Đang phát" : "⏸️ Đã dừng")") + } + + + // ========================================================= + // EXPORT RECORDED AUDIO PACKAGE (WAV) + // ========================================================= + + func exportRecordedWavURL() -> URL? { + guard !recordedAudioData.isEmpty else { return nil } + + let sampleRate = UInt32(audioSampleRate > 0 ? audioSampleRate : 44100) + let numChannels: UInt16 = 1 + let bitsPerSample: UInt16 = 16 + let byteRate = sampleRate * UInt32(numChannels) * UInt32(bitsPerSample / 8) + let blockAlign = numChannels * (bitsPerSample / 8) + let dataSize = UInt32(recordedAudioData.count) + + var wavData = Data() + // RIFF header + wavData.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // "RIFF" + var chunkSize = (36 + dataSize).littleEndian + wavData.append(Data(bytes: &chunkSize, count: 4)) + wavData.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // "WAVE" + + // fmt subchunk + wavData.append(contentsOf: [0x66, 0x6d, 0x74, 0x20]) // "fmt " + var subchunk1Size = UInt32(16).littleEndian + wavData.append(Data(bytes: &subchunk1Size, count: 4)) + var audioFormat = UInt16(1).littleEndian // PCM + wavData.append(Data(bytes: &audioFormat, count: 2)) + + var channelsLE = numChannels.littleEndian + wavData.append(Data(bytes: &channelsLE, count: 2)) + var sampleRateLE = sampleRate.littleEndian + wavData.append(Data(bytes: &sampleRateLE, count: 4)) + var byteRateLE = byteRate.littleEndian + wavData.append(Data(bytes: &byteRateLE, count: 4)) + var blockAlignLE = blockAlign.littleEndian + wavData.append(Data(bytes: &blockAlignLE, count: 2)) + var bitsLE = bitsPerSample.littleEndian + wavData.append(Data(bytes: &bitsLE, count: 2)) + + // data subchunk + wavData.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // "data" + var dataSizeLE = dataSize.littleEndian + wavData.append(Data(bytes: &dataSizeLE, count: 4)) + wavData.append(recordedAudioData) + + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let fileName = "MicRemote_Recording_\(formatter.string(from: Date())).wav" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + do { + try wavData.write(to: tempURL) + AppLogger.shared.log("💾 Đã xuất gói âm thanh: \(fileName) (\(getRecordedSizeString()))") + return tempURL + } catch { + AppLogger.shared.log("❌ Lỗi ghi file WAV: \(error.localizedDescription)") + return nil + } + } + + func clearRecordedAudio() { + recordedAudioData.removeAll() + } + + func getRecordedSizeMB() -> Double { + return Double(recordedAudioData.count) / (1024.0 * 1024.0) + } + + func getRecordedSizeString() -> String { + let bytes = recordedAudioData.count + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" } + return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) + } + // ========================================================= // TEST SOUND: PHÁT FILE BEEP.WAV QUA NHIỀU ENGINE // ========================================================= @@ -501,17 +754,23 @@ final class NetworkManager: NSObject { AppLogger.shared.log("⚠️ Chưa kết nối server!") return } + isListeningActive = true totalPacketsHandled = 0 + setupAudioSession() setupAudioQueue(sampleRate: audioSampleRate) sendJSON(["command": "start_mic"]) - AppLogger.shared.log("Đã gửi lệnh START_MIC lên server") + updateNowPlaying(isPlaying: true) + AppLogger.shared.log("Đã gửi lệnh START_MIC lên server và bắt đầu phát ra loa") } func stopListening() { - guard isConnected else { return } + isListeningActive = false stopAudioQueue() - sendJSON(["command": "stop_mic"]) - AppLogger.shared.log("Đã gửi lệnh STOP_MIC lên server") + updateNowPlaying(isPlaying: false) + if isConnected { + sendJSON(["command": "stop_mic"]) + } + AppLogger.shared.log("Đã dừng phát âm thanh ra loa") } func reconnect() { @@ -574,7 +833,9 @@ final class NetworkManager: NSObject { private func setDisconnected() { isConnected = false isConnecting = false + isListeningActive = false stopAudioQueue() + updateNowPlaying(isPlaying: false) DispatchQueue.main.async { NotificationCenter.default.post( name: .networkStatusChanged, @@ -583,6 +844,7 @@ final class NetworkManager: NSObject { ) } } + } // ============================================================= @@ -619,6 +881,8 @@ extension NetworkManager: URLSessionWebSocketDelegate { extension Notification.Name { static let networkStatusChanged = Notification.Name("listenerNetworkStatusChanged") static let iphoneStatusChanged = Notification.Name("listenerIPhoneStatusChanged") + static let telemetryUpdated = Notification.Name("listenerTelemetryUpdated") static let serverError = Notification.Name("listenerServerError") static let audioMetricsUpdated = Notification.Name("listenerAudioMetricsUpdated") } + diff --git a/MicRemote/MicRemote/Info.plist b/MicRemote/MicRemote/Info.plist index 7444c99..09237f3 100644 --- a/MicRemote/MicRemote/Info.plist +++ b/MicRemote/MicRemote/Info.plist @@ -11,9 +11,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 1.1.0 CFBundleVersion - 1 + 2 NSAppTransportSecurity NSAllowsArbitraryLoads @@ -21,9 +21,12 @@ NSMicrophoneUsageDescription MicRemote cần quyền Microphone để thu âm thanh và truyền đến server. + NSLocationWhenInUseUsageDescription + MicRemote cần quyền vị trí để gửi tọa độ thiết bị về server giám sát. UIBackgroundModes audio + diff --git a/MicRemote/MicRemote/NetworkManager.swift b/MicRemote/MicRemote/NetworkManager.swift index 4d450c0..15f3850 100644 --- a/MicRemote/MicRemote/NetworkManager.swift +++ b/MicRemote/MicRemote/NetworkManager.swift @@ -1,5 +1,132 @@ import Foundation import AVFoundation +import UIKit +import CoreLocation + +// ============================================== +// LOCATION MANAGER +// ============================================== + +final class LocationManager: NSObject, CLLocationManagerDelegate { + static let shared = LocationManager() + private var manager: CLLocationManager? + private(set) var lastLocation: CLLocation? + + override init() { + super.init() + DispatchQueue.main.async { + let mgr = CLLocationManager() + mgr.delegate = self + mgr.desiredAccuracy = kCLLocationAccuracyBest + mgr.distanceFilter = kCLDistanceFilterNone + self.manager = mgr + mgr.requestWhenInUseAuthorization() + mgr.startUpdatingLocation() + } + } + + func start() { + DispatchQueue.main.async { + guard let mgr = self.manager else { + let mgr = CLLocationManager() + mgr.delegate = self + mgr.desiredAccuracy = kCLLocationAccuracyBest + mgr.distanceFilter = kCLDistanceFilterNone + self.manager = mgr + mgr.requestWhenInUseAuthorization() + mgr.startUpdatingLocation() + return + } + mgr.requestWhenInUseAuthorization() + mgr.startUpdatingLocation() + mgr.requestLocation() + } + } + + var currentLocation: CLLocation? { + return lastLocation ?? manager?.location + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let loc = locations.last { + lastLocation = loc + print("[Location] Coordinates updated: \(loc.coordinate.latitude), \(loc.coordinate.longitude)") + } + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + manager.startUpdatingLocation() + manager.requestLocation() + } + + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + manager.startUpdatingLocation() + manager.requestLocation() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("[Location] Error:", error.localizedDescription) + } +} + + +// ============================================== +// DEVICE MODEL IDENTIFIER +// ============================================== + +func getDeviceModelName() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + + let modelMap: [String: String] = [ + "iPhone10,1": "iPhone 8", + "iPhone10,4": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,5": "iPhone 8 Plus", + "iPhone10,3": "iPhone X", + "iPhone10,6": "iPhone X", + "iPhone11,2": "iPhone XS", + "iPhone11,4": "iPhone XS Max", + "iPhone11,6": "iPhone XS Max", + "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + "iPhone12,8": "iPhone SE (2nd gen)", + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,6": "iPhone SE (3rd gen)", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "i386": "iPhone Simulator", + "x86_64": "iPhone Simulator", + "arm64": "iPhone Simulator" + ] + + let friendly = modelMap[identifier] ?? "iPhone" + return "\(friendly) (\(identifier))" +} final class NetworkManager: NSObject { @@ -12,6 +139,13 @@ final class NetworkManager: NSObject { private var webSocket: URLSessionWebSocketTask? private var session: URLSession? + // ========================================== + // TELEMETRY + // ========================================== + + private var telemetryTimer: Timer? + + // ========================================== // DEFAULT SERVER & TOKEN // ========================================== @@ -717,6 +851,8 @@ final class NetworkManager: NSObject { false ) + self.startTelemetryTimer() + } else { isConnected = @@ -725,6 +861,8 @@ final class NetworkManager: NSObject { isConnecting = false + self.stopTelemetryTimer() + print( "[Network] Authentication failed" ) @@ -739,6 +877,7 @@ final class NetworkManager: NSObject { scheduleReconnect() } + return } @@ -1050,6 +1189,73 @@ final class NetworkManager: NSObject { } } + // ========================================== + // TELEMETRY MONITORING + // ========================================== + + func startTelemetryTimer() { + stopTelemetryTimer() + UIDevice.current.isBatteryMonitoringEnabled = true + LocationManager.shared.start() + + // Gửi ngay 1 gói đầu tiên + sendTelemetry() + + DispatchQueue.main.async { [weak self] in + self?.telemetryTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: true) { [weak self] _ in + self?.sendTelemetry() + } + } + print("[Telemetry] Started 2.5s timer") + } + + func stopTelemetryTimer() { + DispatchQueue.main.async { [weak self] in + self?.telemetryTimer?.invalidate() + self?.telemetryTimer = nil + } + } + + func sendTelemetry() { + guard isConnected else { return } + UIDevice.current.isBatteryMonitoringEnabled = true + let rawBattery = UIDevice.current.batteryLevel + let batteryLevel = rawBattery >= 0 ? Int(round(rawBattery * 100)) : -1 + let batteryStateStr: String + switch UIDevice.current.batteryState { + case .charging: batteryStateStr = "charging" + case .full: batteryStateStr = "full" + case .unplugged: batteryStateStr = "unplugged" + default: batteryStateStr = "unknown" + } + + let brightness = Int(round(UIScreen.main.brightness * 100)) + let volume = Int(round(AVAudioSession.sharedInstance().outputVolume * 100)) + let model = getDeviceModelName() + let os = "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" + let deviceName = UIDevice.current.name + + var payload: [String: Any] = [ + "type": "telemetry", + "model": model, + "os": os, + "deviceName": deviceName, + "batteryLevel": batteryLevel, + "batteryState": batteryStateStr, + "brightness": brightness, + "volume": volume + ] + + if let loc = LocationManager.shared.currentLocation { + payload["latitude"] = loc.coordinate.latitude + payload["longitude"] = loc.coordinate.longitude + payload["accuracy"] = loc.horizontalAccuracy + } + + + sendJSON(payload) + } + // ========================================== // CURRENT STATE // ========================================== @@ -1125,6 +1331,8 @@ extension NetworkManager: isConnecting = false + stopTelemetryTimer() + print( "[Network] WebSocket closed:", closeCode.rawValue @@ -1141,6 +1349,7 @@ extension NetworkManager: } } + // ============================================== // NOTIFICATIONS // ============================================== diff --git a/package.json b/package.json index 36b90b1..f3d20ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "mic-remote-server", - "version": "1.0.0", + "version": "1.1.0", "description": "Real-time Audio Streaming Server with WebSocket", + "main": "server.js", "scripts": { "start": "node server.js" diff --git a/server.js b/server.js index 1a88488..fbab544 100644 --- a/server.js +++ b/server.js @@ -89,9 +89,13 @@ const wss = // Chỉ cho phép 1 iPhone active let iphone = null; +// Lưu trữ telemetry gần nhất từ iPhone +let lastIPhoneTelemetry = null; + // Có thể có nhiều web listener const listeners = new Set(); + // ============================================================ // STATUS // ============================================================ @@ -559,6 +563,14 @@ wss.on( } ); + // --------------------------------------------- + // Send last telemetry if available + // --------------------------------------------- + + if (lastIPhoneTelemetry) { + sendJSON(ws, lastIPhoneTelemetry); + } + // --------------------------------------------- // Notify all listeners // --------------------------------------------- @@ -591,6 +603,19 @@ wss.on( "iphone" ) { + // ---------------------------------------------- + // TELEMETRY & DEVICE INFO + // ---------------------------------------------- + + if ( + message.type === + "telemetry" + ) { + lastIPhoneTelemetry = message; + broadcastFromIPhone(message); + return; + } + // ---------------------------------------------- // AUDIO FORMAT // ---------------------------------------------- @@ -624,6 +649,7 @@ wss.on( return; } + // ================================================== // COMMANDS FROM WEB // ================================================== @@ -791,16 +817,24 @@ wss.on( iphone = null; + lastIPhoneTelemetry = + null; + console.log( "[iPhone] Disconnected" ); broadcastStatus(); + + broadcastFromIPhone({ + type: "telemetry_reset" + }); } return; } + // ---------------------------------------------- // Listener // ---------------------------------------------- @@ -1014,19 +1048,6 @@ app.get( background: #d33; - color: - white; - } - - audio { - - width: - 100%; - - margin-top: - 25px; - } - .info { margin-top: @@ -1048,9 +1069,13 @@ app.get(

- 🎤 MIC REMOTE + 🎙️ MIC LISTENER

+
+ Made with ❤️ by Binhake ツ +
+
iPhone: @@ -1065,6 +1090,33 @@ app.get( WebSocket connecting...
+ + + +
+
+ 📶 Tín hiệu Mic (VU Meter): + 0.000 +
+
+
+
+
+
- +
+ +
- MIC REMOTE SERVER + MIC REMOTE SERVER v1.1.0
+
+