Compare commits

...
Author SHA1 Message Date
callebtc 991def1deb realtime waveform 2025-09-18 19:03:19 +02:00
callebtc ac7feb380e live recording 2025-09-18 18:59:49 +02:00
callebtc 57c5d81981 fix waveform alignment 2025-09-18 18:34:54 +02:00
callebtc fa04e801b6 image size 2025-09-18 18:24:23 +02:00
callebtc 3c21783eae left corners rounded, right not yet 2025-09-18 18:20:12 +02:00
callebtc b196a55c37 adjust TLV sizes 2025-09-18 17:19:51 +02:00
callebtc ade700e7f0 file transfer wip 2025-09-18 17:12:58 +02:00
callebtc ea7b90c074 images wip 2025-09-17 01:31:34 +02:00
callebtc 5486fc6a69 increase amplitude 2025-09-16 22:49:18 +02:00
callebtc 7415039183 use v2 packet for FILE_TRANSFER 2025-09-16 22:34:24 +02:00
callebtc 7b73728f1d logging file type 2025-09-15 21:01:20 +02:00
callebtc 1ea90295ce revert 2025-09-15 21:01:04 +02:00
callebtc c9fac83433 wip 2025-09-15 20:04:09 +02:00
callebtc 5b799e1a50 wip voice 2025-09-15 19:57:02 +02:00
22 changed files with 1418 additions and 247 deletions
+8 -130
View File
@@ -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 = "<group>"; };
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 = "<group>";
};
@@ -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 */
@@ -0,0 +1,91 @@
import Foundation
#if os(iOS)
import AVFoundation
import CoreGraphics
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()
}
}
// MARK: - Live metrics for visualizer
/// Returns normalized amplitude [0,1] based on average power in dB.
/// Call periodically while recording to drive a live waveform.
func pollNormalizedAmplitude() -> CGFloat {
guard let r = recorder, isRecording else { return 0 }
r.updateMeters()
// Use peak power for responsiveness, map dB [-160,0] -> linear [0,1]
let db = r.peakPower(forChannel: 0)
let linear = pow(10.0, db / 20.0) // 0..1
if linear.isNaN || linear.isInfinite { return 0 }
// Keep linear to avoid globally scaling up low amplitudes
return CGFloat(max(0, min(1, linear)))
}
/// Current elapsed recording time in milliseconds.
func currentTimeMs() -> Int {
guard let r = recorder, isRecording else { return 0 }
return Int(r.currentTime * 1000)
}
}
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)
}
}
#else
/// Minimal macOS-compatible stubs so the macOS target builds without AVFAudio.
final class VoiceRecorder: NSObject {
private(set) var isRecording = false
func startRecording(to url: URL) throws {
// Voice recording is iOS-only in this project
throw NSError(domain: "VoiceRecorder", code: -1, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
}
func stopRecording(completion: @escaping () -> Void) { completion() }
}
enum VoiceRecorderPaths {
static func outgoingURL() throws -> URL {
throw NSError(domain: "VoiceRecorder", code: -2, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
}
}
#endif
+76
View File
@@ -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<Int8>?
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..<end]
var sum: Float = 0
vDSP_svesq(slice.map { $0 }, 1, &sum, vDSP_Length(slice.count))
let rms = sqrtf(sum / Float(slice.count))
bins.append(min(1.0, rms))
i = end
}
if bins.count < binCount {
bins += Array(repeating: 0, count: binCount - bins.count)
}
WaveformCache.shared.set(bins, for: url.path)
return bins
}
}
+6
View File
@@ -33,12 +33,18 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>bitchat saves received images to your photo library when you choose to save them.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat needs access to your photos to let you pick images to send.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record and send short voice notes to nearby peers.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
+133
View File
@@ -0,0 +1,133 @@
//
// BitchatFilePacket.swift
// bitchat
//
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
//
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: UInt32
let mimeType: String
let content: Data
func encode() -> Data? {
var out = Data()
// Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length)
func appendStandardTLV(_ 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) {
appendStandardTLV(.fileName, nameData)
}
// FILE_SIZE (4 bytes, UInt32 BE) - v2 spec
out.append(TLVType.fileSize.rawValue)
out.append(UInt8(0)) // Length high byte = 0
out.append(UInt8(4)) // Length low byte = 4
let size32 = UInt32(min(fileSize, UInt32.max))
out.append(UInt8((size32 >> 24) & 0xFF))
out.append(UInt8((size32 >> 16) & 0xFF))
out.append(UInt8((size32 >> 8) & 0xFF))
out.append(UInt8(size32 & 0xFF))
// MIME_TYPE
if let mimeData = mimeType.data(using: .utf8) {
appendStandardTLV(.mimeType, mimeData)
}
// CONTENT (4-byte length) - v2 spec
out.append(TLVType.content.rawValue)
let contentLen = UInt32(content.count)
out.append(UInt8((contentLen >> 24) & 0xFF))
out.append(UInt8((contentLen >> 16) & 0xFF))
out.append(UInt8((contentLen >> 8) & 0xFF))
out.append(UInt8(contentLen & 0xFF))
out.append(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])
}
func read32() -> UInt32? {
guard let d = read(4) else { return nil }
return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3])
}
var name: String?
var size: UInt32?
var mime: String?
var contentData = Data()
while idx < data.count {
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
// CONTENT uses 4-byte length; others use 2-byte length
let len: Int
if tlvType == .content {
guard let len32 = read32() else { return nil }
len = Int(len32)
} else {
guard let len16 = read16() else { return nil }
len = Int(len16)
}
guard len >= 0, idx + len <= data.count else { return nil }
let val = data.subdata(in: idx..<(idx+len))
idx += len
switch tlvType {
case .fileName:
name = String(data: val, encoding: .utf8)
case .fileSize:
guard len == 4 else { return nil } // v2 spec: 4 bytes
var s: UInt32 = 0
for b in val { s = (s << 8) | UInt32(b) }
size = s
case .mimeType:
mime = String(data: val, encoding: .utf8)
case .content:
// Expect single CONTENT TLV; if multiple, concatenate defensively
contentData.append(val)
}
}
// Validate
guard !contentData.isEmpty else { return nil }
let finalSize = size ?? UInt32(contentData.count)
let finalName = name ?? "file"
let finalMime = mime ?? "application/octet-stream"
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData)
}
}
extension Data { func sha256Hex() -> String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } }
+15 -1
View File
@@ -21,7 +21,20 @@ struct BitchatPacket: Codable {
let payload: Data
var signature: Data?
var ttl: UInt8
// Full initialization with explicit version
init(version: UInt8, type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = version
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Backward compatible constructor (defaults to v1)
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
@@ -74,6 +87,7 @@ struct BitchatPacket: Codable {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
version: version, // Preserve packet version for signing
type: type,
senderID: senderID,
recipientID: recipientID,
+54 -22
View File
@@ -22,11 +22,12 @@
///
/// ## Wire Format
/// ```
/// Header (Fixed 13 bytes):
/// +--------+------+-----+-----------+-------+----------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
/// +--------+------+-----+-----------+-------+----------------+
/// Header (13 bytes for v1, 15 bytes for v2):
/// +--------+------+-----+-----------+-------+---------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) |
/// | | | | | | 4 bytes (v2) |
/// +--------+------+-----+-----------+-------+---------------+
///
/// Variable sections:
/// +----------+-------------+---------+------------+
@@ -56,9 +57,10 @@
/// - Bits 3-7: Reserved for future use
///
/// ## Size Constraints
/// - Maximum packet size: 65,535 bytes (16-bit length field)
/// - Maximum packet size: 4.2GB (32-bit length field for v2+)
/// - Typical packet size: < 512 bytes (BLE MTU)
/// - Minimum packet size: 21 bytes (header + sender ID)
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
///
/// ## Encoding Process
/// 1. Construct header with fixed fields
@@ -105,16 +107,22 @@ extension Data {
/// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol {
static let headerSize = 13
static let headerSizeV1 = 13
static let headerSizeV2 = 15
static let senderIDSize = 8
static let recipientIDSize = 8
static let signatureSize = 64
struct Flags {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
}
/// Get header size based on protocol version
static func getHeaderSize(_ version: UInt8) -> Int {
return version >= 2 ? headerSizeV2 : headerSizeV1
}
// Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
@@ -138,9 +146,10 @@ struct BinaryProtocol {
} else {
}
// Header
// Header with version-dependent size
let headerSize = getHeaderSize(packet.version)
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
// header + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
@@ -166,13 +175,20 @@ struct BinaryProtocol {
}
data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2)
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
let payloadLength = UInt16(payloadDataSize)
data.append(UInt8((payloadLength >> 8) & 0xFF))
data.append(UInt8(payloadLength & 0xFF))
if packet.version >= 2 {
// v2+: 4 bytes big-endian
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
data.append(UInt8((payloadDataSize >> 16) & 0xFF))
data.append(UInt8((payloadDataSize >> 8) & 0xFF))
data.append(UInt8(payloadDataSize & 0xFF))
} else {
// v1: 2 bytes big-endian
let payloadLength16 = UInt16(payloadDataSize)
data.append(UInt8((payloadLength16 >> 8) & 0xFF))
data.append(UInt8(payloadLength16 & 0xFF))
}
// SenderID (exactly 8 bytes)
let senderBytes = packet.senderID.prefix(senderIDSize)
@@ -227,8 +243,8 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { return nil }
// Minimum size: smallest header (v1) + senderID
guard raw.count >= headerSizeV1 + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
@@ -249,6 +265,14 @@ struct BinaryProtocol {
offset += 2
return v
}
// Read big-endian 32-bit
func read32() -> UInt32? {
guard require(4) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt32(p[0]) << 24) | (UInt32(p[1]) << 16) | (UInt32(p[2]) << 8) | UInt32(p[3])
offset += 4
return v
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
@@ -258,8 +282,8 @@ struct BinaryProtocol {
return d
}
// Version
guard let version = read8(), version == 1 else { return nil }
// Version - accept both v1 and v2+
guard let version = read8(), version >= 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
@@ -277,8 +301,15 @@ struct BinaryProtocol {
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+)
let payloadLen: UInt32
if version >= 2 {
guard let len32 = read32(), len32 <= 4_294_967_295 else { return nil }
payloadLen = len32
} else {
guard let len16 = read16(), len16 <= 65535 else { return nil }
payloadLen = UInt32(len16)
}
// SenderID
guard let senderID = readData(senderIDSize) else { return nil }
@@ -317,6 +348,7 @@ struct BinaryProtocol {
guard offset <= buf.count else { return nil }
return BitchatPacket(
version: version,
type: type,
senderID: senderID,
recipientID: recipientID,
+2
View File
@@ -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"
}
}
}
+191 -1
View File
@@ -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,127 @@ 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(
version: 2, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
type: MessageType.fileTransfer.rawValue,
senderID: myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
// Log debug info about the file TLV
if let fp = BitchatFilePacket.decode(from: payload) {
let sha = fp.content.sha256Hex()
let scope = (recipientData == nil || recipientData == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
SecureLogger.debug("📤 FILE_TRANSFER send (\(scope)) name=\(fp.fileName) mime=\(fp.mimeType) size=\(fp.fileSize) sha256=\(sha)", category: .session)
} else {
SecureLogger.warning("⚠️ FILE_TRANSFER send: failed to decode TLV for logging (payload=\(payload.count) bytes)", category: .session)
}
// 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..<min(offset + chunk, fullData.count)])
}
let total = fragments.count
// Pause scanning for long trains as in sendFragmentedPacket
if total > 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 +1539,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 +1584,72 @@ 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
}
let sha = file.content.sha256Hex()
let scope = (packet.recipientID == nil || packet.recipientID == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
SecureLogger.debug("📥 FILE_TRANSFER recv (\(scope)) from=\(peerID.prefix(8))… name=\(file.fileName) mime=\(file.mimeType) size=\(file.fileSize) sha256=\(sha)", category: .session)
// Determine if this is a true direct message; Android may use 0xFF..FF to denote broadcast
let isBroadcastRecipient: Bool = {
guard let rid = packet.recipientID else { return true }
return rid == Data(repeating: 0xFF, count: 8)
}()
// 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) && !isBroadcastRecipient,
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 {
+4
View File
@@ -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> {
+3
View File
@@ -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)
@@ -0,0 +1,7 @@
// ChatViewModel+Extensions.swift
// bitchat
//
// Deprecated helper. No longer needed after MessageHeaderLine simplification.
// Intentionally left empty to avoid accessing private properties across files.
// This file remains as a placeholder to preserve project references if any.
@@ -0,0 +1,113 @@
// ChatViewModel+Images.swift
// bitchat
//
// Extracted image sending helpers to keep ChatViewModel compact.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#endif
extension ChatViewModel {
// MARK: - Images (Send)
#if os(iOS)
@MainActor
func sendImage(_ image: UIImage) {
// Downscale on long edge to 512px with 85% JPEG quality (Android parity)
guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return }
// Persist to app files (outgoing)
guard let fileURL = saveOutgoingImage(data: data) else { return }
let name = fileURL.lastPathComponent
let mime = "image/jpeg"
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
guard let payload = tlv.encode() else { return }
let transferId = payload.sha256Hex()
let contentMarker = "[image] \(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 is private in ChatViewModel; inline minimal append
var arr = privateChats[peer] ?? []
arr.append(msg)
privateChats[peer] = arr
objectWillChange.send()
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
)
// public buffer helpers are private; append directly to visible messages
messages.append(msg)
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
}
}
// Downscale without cropping; preserves aspect ratio. Long edge == maxDimension.
internal func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? {
let size = image.size
guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) }
let maxSide = max(size.width, size.height)
let scale = min(1.0, maxDimension / maxSide)
let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale))
let rendererFormat = UIGraphicsImageRendererFormat.default()
rendererFormat.scale = 1
let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat)
let scaled = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
return scaled.jpegData(compressionQuality: quality)
}
internal func saveOutgoingImage(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_images/outgoing", isDirectory: true)
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
let ts = Int(Date().timeIntervalSince1970)
let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg")
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
// Avoid BitLogger dependency in this small extension
print("❌ Failed to save outgoing image: \(error)")
return nil
}
}
#endif
}
+67 -1
View File
@@ -86,6 +86,9 @@ import CommonCrypto
import CoreBluetooth
#if os(iOS)
import UIKit
#if canImport(PhotosUI)
import PhotosUI
#endif
#endif
/// Manages the application state and business logic for BitChat.
@@ -3768,7 +3771,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
@MainActor
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
@@ -6036,6 +6039,7 @@ private func checkForMentions(_ message: BitchatMessage) {
impactFeedback.prepare()
for i in 0..<8 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) {
impactFeedback.impactOccurred()
}
@@ -6048,5 +6052,67 @@ 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: UInt32(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)
}
}
// Images code moved to ChatViewModel+Images.swift
@MainActor
private func appendToPrivateChat(peerID: String, message: BitchatMessage) {
var arr = privateChats[peerID] ?? []
arr.append(message)
privateChats[peerID] = arr
objectWillChange.send()
}
}
// End of ChatViewModel class
+292 -89
View File
@@ -9,6 +9,7 @@
import SwiftUI
#if os(iOS)
import UIKit
import PhotosUI
#endif
// MARK: - Supporting Types
@@ -57,6 +58,21 @@ struct ContentView: View {
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:]
// Measure input field height so recorder overlay matches it
@State private var inputFieldMeasuredHeight: CGFloat = 0
private struct InputHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
#if os(iOS)
@State private var voiceRecorder = VoiceRecorder()
@State private var isRecordingVoice = false
@State private var recordingTimer: Timer? = nil
@State private var recordingElapsedMs: Int = 0
@State private var recordingAmplitudeNorm: CGFloat = 0
@State private var currentRecordingURL: URL? = nil
@State private var isShowingImagePicker = false
#endif
// MARK: - Computed Properties
@@ -252,6 +268,16 @@ struct ContentView: View {
scrollThrottleTimer?.invalidate()
autocompleteDebounceTimer?.invalidate()
}
#if os(iOS)
.sheet(isPresented: $isShowingImagePicker) {
ImagePickerView { image in
if let img = image {
viewModel.sendImage(img)
}
isShowingImagePicker = false
}
}
#endif
}
// MARK: - Message List View
@@ -305,20 +331,57 @@ 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] ")
let isImage = message.content.hasPrefix("[image] ")
if isVoice {
// Header line above media: sender + timestamp
MessageHeaderLine(message: message)
.environmentObject(viewModel)
// 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 if isImage {
// Header line above image: sender + timestamp
MessageHeaderLine(message: message)
.environmentObject(viewModel)
// Hide the raw marker text; render image view
let path = String(message.content.dropFirst("[image] ".count))
ImageMessageRow(path: path, message: message)
.environmentObject(viewModel)
} 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
@@ -795,92 +858,171 @@ struct ContentView: View {
}
HStack(alignment: .center, spacing: 4) {
TextField("type a message...", text: $messageText)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
.focused($isTextFieldFocused)
.padding(.leading, 12)
// iOS keyboard autocomplete and capitalization enabled by default
.onChange(of: messageText) { newValue in
// Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate()
// Debounce autocomplete updates to reduce calls during rapid typing
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
ZStack(alignment: .leading) {
// Always keep the TextField mounted so the keyboard state doesn't change
TextField("type a message...", text: $messageText)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
.focused($isTextFieldFocused)
.padding(.leading, 12)
.opacity(isRecordingVoice ? 0.0 : 1.0)
// Measure the text field height so we can match recorder height
.background(GeometryReader { geo in
Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
})
// Recording overlay: live waveform + elapsed/max time, same height as input field
if isRecordingVoice {
HStack(alignment: .center, spacing: 12) {
RealtimeScrollingWaveform(
amplitudeNorm: recordingAmplitudeNorm,
bars: 240,
barColor: (colorScheme == .dark ? Color.green : Color(red: 0, green: 0.6, blue: 0.3))
)
.frame(height: max(28, inputFieldMeasuredHeight))
let secs = max(0, recordingElapsedMs / 1000)
let mm = secs / 60
let ss = secs % 60
Text(String(format: "%02d:%02d / 00:10", mm, ss))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor)
.padding(.trailing, 4)
}
// Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
let isGeoPublic: Bool = {
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
var commandDescriptions = [
("/block", "block or list blocked peers"),
("/clear", "clear chat messages"),
("/hug", "send someone a warm hug"),
("/m", "send private message"),
("/slap", "slap someone with a trout"),
("/unblock", "unblock a peer"),
("/w", "see who's online")
]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", "add to favorites"))
commandDescriptions.append(("/unfav", "remove from favorites"))
}
let input = newValue.lowercased()
// Map of aliases to primary commands
let aliases: [String: String] = [
"/join": "/j",
"/msg": "/m"
]
// Filter commands, but convert aliases to primary
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
// Also check if input matches an alias
for (alias, primary) in aliases {
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
if commandDescriptions.contains(where: { $0.0 == primary }) {
commandSuggestions.append(primary)
}
.padding(.leading, 12)
.frame(height: max(28, inputFieldMeasuredHeight))
}
}
.onChange(of: messageText) { newValue in
// Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate()
// Debounce autocomplete updates to reduce calls during rapid typing
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
}
// Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
let isGeoPublic: Bool = {
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
var commandDescriptions = [
("/block", "block or list blocked peers"),
("/clear", "clear chat messages"),
("/hug", "send someone a warm hug"),
("/m", "send private message"),
("/slap", "slap someone with a trout"),
("/unblock", "unblock a peer"),
("/w", "see who's online")
]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", "add to favorites"))
commandDescriptions.append(("/unfav", "remove from favorites"))
}
let input = newValue.lowercased()
// Map of aliases to primary commands
let aliases: [String: String] = [
"/join": "/j",
"/msg": "/m"
]
// Filter commands, but convert aliases to primary
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
// Also check if input matches an alias
for (alias, primary) in aliases {
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
if commandDescriptions.contains(where: { $0.0 == primary }) {
commandSuggestions.append(primary)
}
}
// Remove duplicates and sort
commandSuggestions = Array(Set(commandSuggestions)).sorted()
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
commandSuggestions = []
}
// Remove duplicates and sort
commandSuggestions = Array(Set(commandSuggestions)).sorted()
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
commandSuggestions = []
}
}
.onSubmit { sendMessage() }
.onPreferenceChange(InputHeightPreferenceKey.self) { inputFieldMeasuredHeight = $0 }
Group {
if messageText.isEmpty {
#if os(iOS)
HStack(spacing: 8) {
// Plus button for image picker (hidden while recording)
if !isRecordingVoice {
Button(action: { showImagePicker() }) {
Image(systemName: "plus.circle")
.font(.system(size: 22))
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel("Add media")
}
Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle")
.font(.system(size: 22))
.foregroundColor(isRecordingVoice ? .red : (viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor))
.highPriorityGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isRecordingVoice {
do {
let url = try VoiceRecorderPaths.outgoingURL()
try voiceRecorder.startRecording(to: url)
currentRecordingURL = url
isRecordingVoice = true
startRecordingMeters()
hapticStart()
} catch {
isRecordingVoice = false
}
}
}
.onEnded { _ in
finishRecordingAndSend()
}
)
}
.padding(.trailing, 12)
#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")
}
.onSubmit {
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)
}
.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))
@@ -900,6 +1042,67 @@ struct ContentView: View {
messageText = ""
}
#if os(iOS)
private func startRecordingMeters() {
recordingTimer?.invalidate()
recordingElapsedMs = 0
recordingAmplitudeNorm = 0
// Poll meters ~12.5Hz for elapsed and ~50Hz for waveform via the view's ticker.
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in
recordingAmplitudeNorm = voiceRecorder.pollNormalizedAmplitude()
recordingElapsedMs = voiceRecorder.currentTimeMs()
if recordingElapsedMs >= 10_000 {
// Auto-stop at 10s
finishRecordingAndSend()
}
}
}
private func finishRecordingAndSend() {
guard isRecordingVoice else { return }
voiceRecorder.stopRecording {
isRecordingVoice = false
recordingTimer?.invalidate(); recordingTimer = nil
hapticStop()
let url = currentRecordingURL
currentRecordingURL = nil
if let u = url {
viewModel.sendVoiceNote(fileURL: u)
} else {
// Fallback to latest file in case we lost the URL
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
}
}
}
}
private func showImagePicker() {
isShowingImagePicker = true
}
private func hapticStart() {
let gen = UIImpactFeedbackGenerator(style: .medium)
gen.prepare(); gen.impactOccurred()
}
private func hapticStop() {
let gen = UINotificationFeedbackGenerator()
gen.prepare(); gen.notificationOccurred(.success)
}
#endif
// MARK: - Sidebar View
private var sidebarView: some View {
+54
View File
@@ -0,0 +1,54 @@
// ImageMessageRow.swift
// bitchat
//
// Renders an image message bubble with rounded corners and optional delivery status.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ImageMessageRow: View {
let path: String
let message: BitchatMessage
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
var body: some View {
#if os(iOS)
if let uiImage = UIImage(contentsOfFile: path) {
VStack(alignment: .leading, spacing: 4) {
// Ensure image aligns with the same left edge as text messages
HStack(spacing: 0) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 300, maxHeight: 400, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
Spacer(minLength: 0)
}
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text("⚠️ Image unavailable")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
Text("📷 Image: \(path)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0))
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
}
+68
View File
@@ -0,0 +1,68 @@
// ImagePickerView.swift
// Simple UIKit-based image picker bridge
import SwiftUI
#if os(iOS)
import UIKit
import PhotosUI
struct ImagePickerView: UIViewControllerRepresentable {
typealias UIViewControllerType = UIViewController
var onPicked: (UIImage?) -> Void
func makeUIViewController(context: Context) -> UIViewController {
if #available(iOS 14.0, *) {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
} else {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
picker.allowsEditing = false
return picker
}
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onPicked: onPicked)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPicked: (UIImage?) -> Void
init(onPicked: @escaping (UIImage?) -> Void) { self.onPicked = onPicked }
// PHPicker
@available(iOS 14.0, *)
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let item = results.first else { onPicked(nil); return }
if item.itemProvider.canLoadObject(ofClass: UIImage.self) {
item.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in
DispatchQueue.main.async {
self.onPicked(object as? UIImage)
}
}
} else {
onPicked(nil)
}
}
// UIImagePicker
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
onPicked(nil)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let image = info[.originalImage] as? UIImage
picker.dismiss(animated: true)
onPicked(image)
}
}
}
#endif
+71
View File
@@ -0,0 +1,71 @@
// MessageHeaderLine.swift
// bitchat
//
// Renders a header like normal messages: "<@nickname> [HH:mm:ss]"
// Used above media rows (images, audio) to match text message style.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
struct MessageHeaderLine: View {
@EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme
let message: BitchatMessage
var body: some View {
Text(formattedHeader())
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 2)
}
private func formattedHeader() -> AttributedString {
var result = AttributedString()
let isDark = colorScheme == .dark
let baseColor = viewModel.peerColor(for: message, isDark: isDark)
// Determine self to bold our own name
let isSelf: Bool = {
if let spid = message.senderPeerID {
return spid == viewModel.meshService.myPeerID
}
return message.sender == viewModel.nickname || message.sender.hasPrefix(viewModel.nickname + "#")
}()
// Split suffix like name#abcd
let (base, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .system(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
// "<@"
result.append(AttributedString("<@").mergingAttributes(senderStyle))
// base name
result.append(AttributedString(base).mergingAttributes(senderStyle))
// optional suffix with lighter color
if !suffix.isEmpty {
var suf = senderStyle
suf.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suf))
}
// Close angle without adding content or ">"
result.append(AttributedString("> ").mergingAttributes(senderStyle))
// Timestamp like normal messages
let ts = AttributedString("[\(message.formattedTimestamp)]")
var tsStyle = AttributeContainer()
tsStyle.foregroundColor = Color.gray.opacity(0.7)
tsStyle.font = .system(size: 10, design: .monospaced)
result.append(ts.mergingAttributes(tsStyle))
return result
}
}
@@ -0,0 +1,60 @@
import SwiftUI
import Combine
/// Real-time scrolling waveform for live recording.
/// Provide a normalized amplitude [0,1]. The view maintains a sliding window of bars.
struct RealtimeScrollingWaveform: View {
var amplitudeNorm: CGFloat
var bars: Int = 240
var barColor: Color = Color(red: 0, green: 1, blue: 0.5) // neon-ish green
@State private var samples: [CGFloat] = []
@State private var ticker = Timer.publish(every: 0.02, on: .main, in: .common).autoconnect()
var body: some View {
GeometryReader { geo in
Canvas { ctx, size in
let w = size.width
let h = size.height
guard w > 0 && h > 0 else { return }
let n = samples.count
guard n > 0 else { return }
let stepX = w / CGFloat(n)
let midY = h / 2
let stroke: CGFloat = 1.2
for i in 0..<n {
let amp = max(0, min(1, samples[i]))
// Amplify only higher amplitudes so quiet parts stay subtle
let t: CGFloat = 0.6
let k: CGFloat = 0.7
let boosted = amp <= t ? amp : min(1, amp + k * (amp - t))
let lineH = max(1, boosted * (h * 0.95))
let x = CGFloat(i) * stepX + stepX / 2
let yTop = midY - lineH / 2
let yBot = midY + lineH / 2
var path = Path()
path.move(to: CGPoint(x: x, y: yTop))
path.addLine(to: CGPoint(x: x, y: yBot))
ctx.stroke(path, with: .color(barColor), lineWidth: stroke)
}
}
.onAppear {
if samples.isEmpty { samples = Array(repeating: 0, count: bars) }
}
.onChange(of: bars) { newVal in
let clamped = max(8, newVal)
samples = Array(samples.suffix(clamped))
if samples.count < clamped {
samples.insert(contentsOf: Array(repeating: 0, count: clamped - samples.count), at: 0)
}
}
.onReceive(ticker) { _ in
let v = max(0, min(1, amplitudeNorm))
samples.append(v)
let overflow = samples.count - bars
if overflow > 0 { samples.removeFirst(overflow) }
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
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(alignment: .center, spacing: 8) {
// Play / Pause control (fixed size similar to Android's 28dp)
Button(action: togglePlay) {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 24))
}
.buttonStyle(.plain)
// Waveform takes remaining width, fixed height, vertically centered
GeometryReader { geo in
let barWidth: CGFloat = 2
let barSpacing: CGFloat = 2
let step = barWidth + barSpacing
let maxBars = max(0, Int(floor(geo.size.width / step)))
let displayCount = min(maxBars, bins.count)
ZStack(alignment: .leading) {
// Base waveform (gray)
HStack(spacing: barSpacing) {
ForEach(0..<displayCount, id: \.self) { i in
let v = bins[i]
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
let h = max(2, CGFloat(normalizedV) * geo.size.height)
VStack { Spacer(minLength: 0); Capsule().fill(Color.gray.opacity(0.4)).frame(width: barWidth, height: h); Spacer(minLength: 0) }
}
}
// Filled progress (blue) or send progress if provided
let activeProgress = sendProgress ?? progress
let filledBars = Int(Double(displayCount) * activeProgress)
HStack(spacing: barSpacing) {
ForEach(0..<max(0, min(displayCount, filledBars)), id: \.self) { i in
let v = bins[i]
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
let h = max(2, CGFloat(normalizedV) * geo.size.height)
VStack { Spacer(minLength: 0); Capsule().fill(Color.blue).frame(width: barWidth, height: h); Spacer(minLength: 0) }
}
}
}
.clipped() // prevent overflow beyond available width/height
}
.frame(height: 36)
// Reserve width for duration to avoid being pushed out by waveform
Text(formattedDuration)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 44, alignment: .trailing)
}
.onAppear(perform: load)
.onDisappear { player?.stop(); isPlaying = false }
}
private var formattedDuration: String {
let total = duration
let mins = Int(total) / 60
let secs = Int(total) % 60
return String(format: "%d:%02d", mins, secs)
}
private func load() {
bins = WaveformExtractor.extractBins(url: fileURL, binCount: 120)
do {
player = try AVAudioPlayer(contentsOf: fileURL)
duration = player?.duration ?? 0
player?.prepareToPlay()
startProgressTimer()
} catch {
// Ignore
}
}
private func togglePlay() {
guard let p = player else { return }
if isPlaying { p.pause(); isPlaying = false }
else { p.play(); isPlaying = true; startProgressTimer() }
}
private func startProgressTimer() {
guard let p = player else { return }
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
if !isPlaying || p.duration == 0 { t.invalidate(); return }
progress = min(1.0, p.currentTime / p.duration)
if progress >= 1.0 { isPlaying = false; t.invalidate() }
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.chat.bitchat</string>
<string>group.chat.bitchat-local</string>
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
+2 -2
View File
@@ -6,9 +6,9 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.chat.bitchat</string>
<string>group.chat.bitchat-local</string>
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict>
</plist>
</plist>