From 5b799e1a50deef9c18a38e4905d676cfff7e86a0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:57:02 +0200 Subject: [PATCH] wip voice --- bitchat.xcodeproj/project.pbxproj | 138 +--------------- bitchat/Features/voice/VoiceRecorder.swift | 51 ++++++ bitchat/Features/voice/Waveform.swift | 76 +++++++++ bitchat/Info.plist | 2 + bitchat/Models/BitchatFilePacket.swift | 93 +++++++++++ bitchat/Protocols/BitchatProtocol.swift | 2 + bitchat/Services/BLEService.swift | 175 ++++++++++++++++++++- bitchat/Services/NostrTransport.swift | 4 + bitchat/Services/Transport.swift | 3 + bitchat/ViewModels/ChatViewModel.swift | 60 +++++++ bitchat/Views/ContentView.swift | 118 +++++++++++--- bitchat/Views/VoiceMessageRow.swift | 86 ++++++++++ bitchat/bitchat-macOS.entitlements | 2 +- bitchat/bitchat.entitlements | 4 +- 14 files changed, 661 insertions(+), 153 deletions(-) create mode 100644 bitchat/Features/voice/VoiceRecorder.swift create mode 100644 bitchat/Features/voice/Waveform.swift create mode 100644 bitchat/Models/BitchatFilePacket.swift create mode 100644 bitchat/Views/VoiceMessageRow.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index cda979db..28ca04e2 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; }; 0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; }; 048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; - 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; @@ -28,13 +27,6 @@ remoteGlobalIDString = AF077EA0474EDEDE2C72716C; remoteInfo = bitchat_iOS; }; - E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; - proxyType = 1; - remoteGlobalIDString = 57CA17A36A2532A6CFF367BB; - remoteInfo = bitchatShareExtension; - }; FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; @@ -50,7 +42,6 @@ dstPath = ""; dstSubfolder = PlugIns; files = ( - 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; }; @@ -60,7 +51,6 @@ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = ""; }; - 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -68,13 +58,6 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Services/TransportConfig.swift, - ); - target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; - }; A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -91,20 +74,12 @@ ); target = 0576A29205865664C0937536 /* bitchat_macOS */; }; - A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - ShareViewController.swift, - ); - target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; - }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ A6E32C972E762EA70032EA8A /* bitchat */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */, A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */, A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */, ); @@ -113,9 +88,6 @@ }; A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */, - ); path = bitchatShareExtension; sourceTree = ""; }; @@ -180,7 +152,6 @@ children = ( 96D0D41CA19EE5A772AA8434 /* bitchat.app */, 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */, - 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */, C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */, 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */, ); @@ -231,19 +202,6 @@ productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */; - buildPhases = ( - 0A08E70F08F55FD5BA8C7EF3 /* Sources */, - ); - buildRules = ( - ); - name = bitchatShareExtension; - productName = bitchatShareExtension; - productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = { isa = PBXNativeTarget; buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */; @@ -274,9 +232,6 @@ ); buildRules = ( ); - dependencies = ( - 6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */, - ); fileSystemSynchronizedGroups = ( A6E32C972E762EA70032EA8A /* bitchat */, ); @@ -315,7 +270,6 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */, 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */, 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */, AF077EA0474EDEDE2C72716C /* bitchat_iOS */, @@ -340,11 +294,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 0A08E70F08F55FD5BA8C7EF3 /* Sources */ = { - isa = PBXSourcesBuildPhase; - files = ( - ); - }; 137ABE739BF20ACDDF8CC605 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( @@ -373,11 +322,6 @@ target = 0576A29205865664C0937536 /* bitchat_macOS */; targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */; }; - 6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; - targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */; - }; D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */; @@ -462,37 +406,6 @@ }; name = Release; }; - 3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; - baseConfigurationReferenceRelativePath = Release.xcconfig; - buildSettings = { - CODE_SIGNING_ALLOWED = YES; - CODE_SIGNING_REQUIRED = YES; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; - CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; - CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; - INFOPLIST_FILE = bitchatShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = bitchat; - IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = "$(MARKETING_VERSION)"; - PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_VERSION = "$(SWIFT_VERSION)"; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Release; - }; 702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; @@ -504,7 +417,7 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 66W2RU3J5T; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -516,6 +429,7 @@ ); MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -564,7 +478,7 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 66W2RU3J5T; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -576,6 +490,7 @@ ); MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -600,7 +515,7 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 66W2RU3J5T; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -611,6 +526,7 @@ MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; @@ -691,7 +607,7 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 66W2RU3J5T; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -702,6 +618,7 @@ MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; @@ -776,37 +693,6 @@ }; name = Debug; }; - DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; - baseConfigurationReferenceRelativePath = Debug.xcconfig; - buildSettings = { - CODE_SIGNING_ALLOWED = YES; - CODE_SIGNING_REQUIRED = YES; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; - CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; - CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; - INFOPLIST_FILE = bitchatShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = bitchat; - IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = "$(MARKETING_VERSION)"; - PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_VERSION = "$(SWIFT_VERSION)"; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Debug; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -850,14 +736,6 @@ ); defaultConfigurationName = Debug; }; - E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */, - 3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */, - ); - defaultConfigurationName = Debug; - }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift new file mode 100644 index 00000000..baca582e --- /dev/null +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -0,0 +1,51 @@ +import Foundation +import AVFoundation + +final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { + private var recorder: AVAudioRecorder? + private(set) var isRecording = false + + func startRecording(to url: URL) throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try session.setActive(true) + + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 32000 + ] + recorder = try AVAudioRecorder(url: url, settings: settings) + recorder?.delegate = self + recorder?.isMeteringEnabled = true + guard recorder?.record() == true else { throw NSError(domain: "VoiceRecorder", code: -1) } + isRecording = true + } + + func stopRecording(completion: @escaping () -> Void) { + guard isRecording else { completion(); return } + // Add 500ms padding to avoid clipping + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.recorder?.stop() + self?.isRecording = false + completion() + } + } +} + +enum VoiceRecorderPaths { + static func outgoingURL() throws -> URL { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true) + if !fm.fileExists(atPath: folder.path) { + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + } + let ts = ISO8601DateFormatter() + ts.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime] + let name = "voice_\(Int(Date().timeIntervalSince1970)).m4a" + return folder.appendingPathComponent(name) + } +} + diff --git a/bitchat/Features/voice/Waveform.swift b/bitchat/Features/voice/Waveform.swift new file mode 100644 index 00000000..69ffd969 --- /dev/null +++ b/bitchat/Features/voice/Waveform.swift @@ -0,0 +1,76 @@ +import Foundation +import AVFoundation +import Accelerate + +struct WaveformCache { + static var shared = WaveformCache() + private var cache: [String: [Float]] = [:] + mutating func set(_ bins: [Float], for path: String) { cache[path] = bins } + func get(_ path: String) -> [Float]? { cache[path] } +} + +enum WaveformExtractor { + static func extractBins(url: URL, binCount: Int = 120) -> [Float] { + if let cached = WaveformCache.shared.get(url.path) { return cached } + let asset = AVURLAsset(url: url) + guard let track = asset.tracks(withMediaType: .audio).first else { return [] } + let reader: AVAssetReader + do { reader = try AVAssetReader(asset: asset) } catch { return [] } + let outputSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsNonInterleaved: false + ] + let output = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings) + output.alwaysCopiesSampleData = false + if reader.canAdd(output) { reader.add(output) } else { return [] } + guard reader.startReading() else { return [] } + + var samples: [Float] = [] + while reader.status == .reading { + guard let buffer = output.copyNextSampleBuffer() else { break } + if let block = CMSampleBufferGetDataBuffer(buffer) { + var length = 0 + var dataPointer: UnsafeMutablePointer? + if CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr, + let base = dataPointer { + // 16-bit signed little endian + let count = length / 2 + var floats = [Float](repeating: 0, count: count) + base.withMemoryRebound(to: Int16.self, capacity: count) { ptr in + vDSP.convertElements(of: UnsafeBufferPointer(start: ptr, count: count), to: &floats) + } + // Normalize to [-1,1] + var maxVal: Float = 32768.0 + vDSP.divide(floats, maxVal, result: &floats) + samples += floats + } + } + CMSampleBufferInvalidate(buffer) + } + + guard !samples.isEmpty, reader.status == .completed || reader.status == .reading else { return [] } + // Reduce to bins by RMS per window + let window = max(1, samples.count / binCount) + var bins: [Float] = [] + bins.reserveCapacity(binCount) + var i = 0 + while i < samples.count && bins.count < binCount { + let end = min(i + window, samples.count) + let slice = samples[i..bitchat uses the camera to scan QR codes to verify peers. NSLocationWhenInUseUsageDescription bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. + NSMicrophoneUsageDescription + bitchat uses the microphone to record and send short voice notes to nearby peers. UIBackgroundModes bluetooth-central diff --git a/bitchat/Models/BitchatFilePacket.swift b/bitchat/Models/BitchatFilePacket.swift new file mode 100644 index 00000000..ba7cd377 --- /dev/null +++ b/bitchat/Models/BitchatFilePacket.swift @@ -0,0 +1,93 @@ +// +// BitchatFilePacket.swift +// bitchat +// +// TLV encoder/decoder for file transfer payloads (audio/images) +// Types: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (8 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (raw) +// + +import Foundation +import CryptoKit + +struct BitchatFilePacket { + enum TLVType: UInt8 { + case fileName = 0x01 + case fileSize = 0x02 + case mimeType = 0x03 + case content = 0x04 + } + + let fileName: String + let fileSize: UInt64 + let mimeType: String + let content: Data + + func encode() -> Data? { + var out = Data() + func appendTLV(_ type: TLVType, _ value: Data) { + out.append(type.rawValue) + let len = UInt16(min(value.count, 0xFFFF)) + out.append(UInt8((len >> 8) & 0xFF)) + out.append(UInt8(len & 0xFF)) + out.append(value.prefix(Int(len))) + } + // FILE_NAME + if let nameData = fileName.data(using: .utf8) { appendTLV(.fileName, nameData) } + // FILE_SIZE (8 bytes, BE) + var sizeBE = Data() + for i in (0..<8).reversed() { sizeBE.append(UInt8((fileSize >> (i * 8)) & 0xFF)) } + appendTLV(.fileSize, sizeBE) + // MIME_TYPE + if let mimeData = mimeType.data(using: .utf8) { appendTLV(.mimeType, mimeData) } + // CONTENT + appendTLV(.content, content) + return out + } + + static func decode(from data: Data) -> BitchatFilePacket? { + var idx = 0 + func read(_ n: Int) -> Data? { + guard idx + n <= data.count else { return nil } + defer { idx += n } + return data.subdata(in: idx..<(idx+n)) + } + func read8() -> UInt8? { read(1)?.first } + func read16() -> UInt16? { + guard let d = read(2) else { return nil } + return (UInt16(d[0]) << 8) | UInt16(d[1]) + } + + var name: String? + var size: UInt64? + var mime: String? + var content = Data() + + while idx < data.count { + guard let t = read8(), let tlvType = TLVType(rawValue: t), let len = read16() else { return nil } + let l = Int(len) + guard let val = read(l) else { return nil } + switch tlvType { + case .fileName: + name = String(data: val, encoding: .utf8) + case .fileSize: + guard l == 8 else { return nil } + var s: UInt64 = 0 + for b in val { s = (s << 8) | UInt64(b) } + size = s + case .mimeType: + mime = String(data: val, encoding: .utf8) + case .content: + content = val + } + } + + // Validate + guard !content.isEmpty else { return nil } + let finalSize = size ?? UInt64(content.count) + let finalName = name ?? "file" + let finalMime = mime ?? "application/octet-stream" + return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: content) + } +} + +extension Data { func sha256Hex() -> String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 9090e56c..f898c0f4 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -78,6 +78,7 @@ enum MessageType: UInt8 { // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages + case fileTransfer = 0x22 // File transfer (audio/images) TLV payload var description: String { switch self { @@ -88,6 +89,7 @@ enum MessageType: UInt8 { case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" + case .fileTransfer: return "fileTransfer" } } } diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 17787715..04f2093f 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -16,7 +16,7 @@ final class BLEService: NSObject { // MARK: - Constants #if DEBUG - static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet + static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // testnet #else static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet #endif @@ -1276,6 +1276,118 @@ final class BLEService: NSObject { } } } + + // File transfer with progress: send TLV payload inside a FILE_TRANSFER packet. + // Reports .partiallyDelivered via delegate for the provided messageID during fragmentation. + func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) { + // Build packet + let recipientData: Data? = recipientPeerID.flatMap { Data(hexString: $0) } + var packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: myPeerIDData, + recipientID: recipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + // For private sends, sign (integrity) + if recipientData != nil { + if let signed = noiseService.signPacket(packet) { packet = signed } + } + + // Encode once to decide if fragmentation is needed on current links + let pad = padPolicy(for: packet.type) + guard let data = packet.toBinaryData(padding: pad) else { return } + + // Determine max link MTU across active links similar to sendOnAllLinks + let states = snapshotPeripheralStates() + var minCentralWriteLen: Int? + for s in states where s.isConnected { + let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse) + minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m + } + var minNotifyLen: Int? + do { + let (centrals, _) = snapshotSubscribedCentrals() + if !centrals.isEmpty { + minNotifyLen = centrals.map { $0.maximumUpdateValueLength }.min() + } + } + + let needsFragment = { + if let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() { + return data.count > minLen + } + return false + }() + + if needsFragment { + let overhead = 13 + 8 + 8 + 13 + let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() ?? defaultFragmentSize + let chunk = max(64, minLen - overhead) + + // Create fragments and emit progress per fragment + guard let fullData = packet.toBinaryData(padding: pad) else { return } + let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + let fragments = stride(from: 0, to: fullData.count, by: chunk).map { offset in + Data(fullData[offset.. 4 { + bleQueue.async { [weak self] in + guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } + if c.isScanning { c.stopScan() } + let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, total * TransportConfig.bleExpectedWritePerFragmentMs) + self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() } + } + } + + for (index, fragment) in fragments.enumerated() { + var fragPayload = Data() + fragPayload.append(fragmentID) + fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) + fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) }) + fragPayload.append(packet.type) + fragPayload.append(fragment) + + let fragmentRecipient: Data? = recipientData + let fragmentPacket = BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: packet.senderID, + recipientID: fragmentRecipient, + timestamp: packet.timestamp, + payload: fragPayload, + signature: nil, + ttl: packet.ttl + ) + let perFragMs = (recipientData != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs + let delayMs = index * perFragMs + + // Emit progress to UI just before scheduling send + notifyUI { [weak self] in + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: index, total: total)) + } + + messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in + self?.broadcastPacket(fragmentPacket) + if index == total - 1 { + self?.notifyUI { [weak self] in + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) + } + } + } + } + } else { + // Single packet path + broadcastPacket(packet) + notifyUI { [weak self] in + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) + } + } + } private func handleFragment(_ packet: BitchatPacket, from peerID: String) { // Don't process our own fragments @@ -1418,6 +1530,9 @@ final class BLEService: NSObject { case .fragment: handleFragment(packet, from: senderID) + + case .fileTransfer: + handleFileTransfer(packet, from: senderID) case .leave: handleLeave(packet, from: senderID) @@ -1460,6 +1575,64 @@ final class BLEService: NSObject { messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) } } + + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: String) { + // Decode TLV + guard let file = BitchatFilePacket.decode(from: packet.payload) else { + SecureLogger.error("❌ Failed to decode file TLV from \(peerID)", category: .session) + return + } + // Persist to app files in type-specific folder + let isAudio = file.mimeType.lowercased().hasPrefix("audio/") + let subfolder = isAudio ? "voicenotes/incoming" : "images/incoming" + let ext: String = { + if let inferred = file.fileName.split(separator: ".").last { return String(inferred) } + if isAudio { return "m4a" } + return "bin" + }() + let unique = "\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(8)).\(ext)" + guard let savedURL = saveToAppFiles(subpath: subfolder, fileName: unique, data: file.content) else { return } + + // Build synthetic message + let nickname = peerNickname(peerID: peerID) ?? "user" + let ts = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000.0) + let marker = isAudio ? "[voice]" : "[image]" + let content = "\(marker) \(savedURL.path)" + + let message = BitchatMessage( + id: UUID().uuidString, + sender: nickname, + content: content, + timestamp: ts, + isRelay: false, + originalSender: nil, + isPrivate: packet.recipientID != nil, + recipientNickname: nil, + senderPeerID: peerID, + mentions: nil, + deliveryStatus: nil + ) + notifyUI { [weak self] in + self?.delegate?.didReceiveMessage(message) + } + } + + private func saveToAppFiles(subpath: String, fileName: String, data: Data) -> URL? { + do { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_\(subpath)", isDirectory: true) + if !fm.fileExists(atPath: folder.path) { + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + } + let url = folder.appendingPathComponent(fileName) + try data.write(to: url, options: .atomic) + return url + } catch { + SecureLogger.error("❌ Failed to save incoming file: \(error)", category: .session) + return nil + } + } private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) { guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 8aee7ab3..bd33d7c8 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -4,6 +4,10 @@ import Combine // Minimal Nostr transport conforming to Transport for offline sending final class NostrTransport: Transport { + func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) { + return + } + weak var delegate: BitchatDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index fe352a83..6ca63527 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -47,6 +47,9 @@ protocol Transport: AnyObject { func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: String) + // Media transfer (file transfer TLV) + func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 15636afa..1045e9e7 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -6048,5 +6048,65 @@ private func checkForMentions(_ message: BitchatMessage) { } #endif } + + // MARK: - Voice Notes (Audio) + @MainActor + func sendVoiceNote(fileURL: URL) { + // Read file data + guard let data = try? Data(contentsOf: fileURL) else { return } + let name = fileURL.lastPathComponent + let mime = "audio/mp4" + let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data) + guard let payload = tlv.encode() else { return } + let transferId = payload.sha256Hex() + + let contentMarker = "[voice] \(fileURL.path)" + let now = Date() + + if let peer = selectedPrivateChatPeer { + let messageID = UUID().uuidString + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: contentMarker, + timestamp: now, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: meshService.peerNickname(peerID: peer), + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + appendToPrivateChat(peerID: peer, message: msg) + meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID) + } else { + let messageID = UUID().uuidString + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: contentMarker, + timestamp: now, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + publicBuffer.append(msg) + schedulePublicFlush() + meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID) + } + } + + @MainActor + private func appendToPrivateChat(peerID: String, message: BitchatMessage) { + var arr = privateChats[peerID] ?? [] + arr.append(message) + privateChats[peerID] = arr + objectWillChange.send() + } } // End of ChatViewModel class diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 4b47d810..653e60bc 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -57,6 +57,10 @@ struct ContentView: View { // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [String: Int] = [:] + #if os(iOS) + @State private var voiceRecorder = VoiceRecorder() + @State private var isRecordingVoice = false + #endif // MARK: - Computed Properties @@ -305,20 +309,44 @@ struct ContentView: View { // Precompute heavy token scans once per row let cashuTokens = message.content.extractCashuTokens() let lightningLinks = message.content.extractLightningLinks() - HStack(alignment: .top, spacing: 0) { - let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty - let isExpanded = expandedMessageIDs.contains(message.id) - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) - .frame(maxWidth: .infinity, alignment: .leading) - - // Delivery status indicator for private messages + let isVoice = message.content.hasPrefix("[voice] ") + if isVoice { + // Hide the raw marker text; render custom voice view + HStack(alignment: .center, spacing: 8) { + let path = String(message.content.dropFirst("[voice] ".count)) + let prog: Double? = { + if let st = message.deliveryStatus { + if case .partiallyDelivered(let r, let t) = st, t > 0 { + return min(1.0, max(0.0, Double(r) / Double(t))) + } + } + return nil + }() + VoiceMessageRow(fileURL: URL(fileURLWithPath: path), sendProgress: prog) + } + .frame(maxWidth: .infinity, alignment: .leading) + // Delivery status for our own private voice messages if message.isPrivate && message.sender == viewModel.nickname, let status = message.deliveryStatus { DeliveryStatusView(status: status, colorScheme: colorScheme) .padding(.leading, 4) } + } else { + HStack(alignment: .top, spacing: 0) { + let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty + let isExpanded = expandedMessageIDs.contains(message.id) + Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) + .frame(maxWidth: .infinity, alignment: .leading) + + // Delivery status indicator for private messages + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status, colorScheme: colorScheme) + .padding(.leading, 4) + } + } } // Expand/Collapse for very long messages @@ -870,17 +898,69 @@ struct ContentView: View { sendMessage() } - Button(action: sendMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 20)) - .foregroundColor(messageText.isEmpty ? Color.gray : - viewModel.selectedPrivateChatPeer != nil - ? Color.orange : textColor) + Group { + if messageText.isEmpty { + #if os(iOS) + Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle") + .font(.system(size: 22)) + .foregroundColor(isRecordingVoice ? .red : (viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)) + .padding(.trailing, 12) + .gesture( + LongPressGesture(minimumDuration: 0.2) + .onChanged { _ in + if !isRecordingVoice { + do { + let url = try VoiceRecorderPaths.outgoingURL() + try voiceRecorder.startRecording(to: url) + isRecordingVoice = true + } catch { + isRecordingVoice = false + } + } + } + .onEnded { _ in + voiceRecorder.stopRecording { + isRecordingVoice = false + // Send the most recent recorded file + do { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true) + let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) + let latest = files.sorted { (a, b) -> Bool in + let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return ad > bd + }.first + if let u = latest { viewModel.sendVoiceNote(fileURL: u) } + } catch { + // ignore + } + } + } + ) + #else + // Non-iOS: keep send button placeholder + Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 20)) + .foregroundColor(Color.gray) + } + .buttonStyle(.plain) + .padding(.trailing, 12) + #endif + } else { + Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 20)) + .foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor) + } + .buttonStyle(.plain) + .padding(.trailing, 12) + .accessibilityLabel("Send message") + .accessibilityHint("Double tap to send") + } } - .buttonStyle(.plain) - .padding(.trailing, 12) - .accessibilityLabel("Send message") - .accessibilityHint(messageText.isEmpty ? "Enter a message to send" : "Double tap to send") } .padding(.vertical, 8) .background(backgroundColor.opacity(0.95)) diff --git a/bitchat/Views/VoiceMessageRow.swift b/bitchat/Views/VoiceMessageRow.swift new file mode 100644 index 00000000..76e7b1ab --- /dev/null +++ b/bitchat/Views/VoiceMessageRow.swift @@ -0,0 +1,86 @@ +import SwiftUI +import AVFoundation + +struct VoiceMessageRow: View { + let fileURL: URL + var sendProgress: Double? = nil // 0..1 filling during send + @State private var bins: [Float] = [] + @State private var isPlaying = false + @State private var progress: Double = 0 + @State private var duration: TimeInterval = 0 + @State private var player: AVAudioPlayer? + + var body: some View { + HStack(spacing: 8) { + Button(action: togglePlay) { + Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 22)) + } + .buttonStyle(.plain) + + GeometryReader { geo in + ZStack(alignment: .leading) { + // Base waveform + HStack(spacing: 2) { + ForEach(Array(bins.enumerated()), id: \.offset) { _, v in + let h = max(2, CGFloat(v) * geo.size.height) + Capsule().fill(Color.gray.opacity(0.4)).frame(width: 2, height: h) + } + } + // Filled progress + let activeProgress = sendProgress ?? progress + let filled = Int(Double(bins.count) * activeProgress) + HStack(spacing: 2) { + ForEach(0..= 1.0 { isPlaying = false; t.invalidate() } + } + } +} diff --git a/bitchat/bitchat-macOS.entitlements b/bitchat/bitchat-macOS.entitlements index a3f413df..73789401 100644 --- a/bitchat/bitchat-macOS.entitlements +++ b/bitchat/bitchat-macOS.entitlements @@ -6,7 +6,7 @@ com.apple.security.application-groups - group.chat.bitchat + group.chat.bitchat-local com.apple.security.device.bluetooth diff --git a/bitchat/bitchat.entitlements b/bitchat/bitchat.entitlements index 610b08cb..bc8f2653 100644 --- a/bitchat/bitchat.entitlements +++ b/bitchat/bitchat.entitlements @@ -6,9 +6,9 @@ com.apple.security.application-groups - group.chat.bitchat + group.chat.bitchat-local com.apple.security.device.bluetooth - \ No newline at end of file +