mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:05:18 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef010dff37 | ||
|
|
8fbfa359ff | ||
|
|
eeb4d55476 | ||
|
|
4882b99752 | ||
|
|
9b8498c78e | ||
|
|
1cf8449e22 | ||
|
|
7ca1ff0a7e | ||
|
|
7316a4a46d | ||
|
|
e992d96939 | ||
|
|
e427e1c62f | ||
|
|
fae76dc515 | ||
|
|
76f976438f | ||
|
|
369c335088 | ||
|
|
a84b8c05f1 | ||
|
|
9d738cff45 | ||
|
|
9879cb2580 | ||
|
|
e1bd8a69c0 | ||
|
|
629bea7fd0 | ||
|
|
1cf4ea9e57 | ||
|
|
3b96d8590d | ||
|
|
6cbfe8ecfb | ||
|
|
8481e1552d | ||
|
|
9ebaa9108c | ||
|
|
2b3264b581 | ||
|
|
830f7ee4b8 | ||
|
|
c8768c32b8 | ||
|
|
1352484491 | ||
|
|
ef396936a3 | ||
|
|
c3ce32ad07 | ||
|
|
d7ca99ae5d | ||
|
|
b25f0d9f63 | ||
|
|
abb998d809 | ||
|
|
d71befd8b7 | ||
|
|
78c4bed1ad | ||
|
|
c52a2a7772 | ||
|
|
3df0aa0cc4 | ||
|
|
46caded099 | ||
|
|
a04ac9dabd | ||
|
|
1825d115bc | ||
|
|
9754881af8 | ||
|
|
59cc857fde | ||
|
|
f0ca1b27c2 | ||
|
|
5beff8b4dc | ||
|
|
ba49b885bb | ||
|
|
ac10e906c0 | ||
|
|
119e882574 | ||
|
|
0acbbdf2e3 | ||
|
|
40fe0c8ba5 | ||
|
|
7593053137 | ||
|
|
4dde063027 | ||
|
|
8c4fead945 | ||
|
|
2543277755 | ||
|
|
a8ce4cbf64 | ||
|
|
93aa714ed4 | ||
|
|
9c7bf26e13 | ||
|
|
c29c7d83fe | ||
|
|
7f743d48fe | ||
|
|
27c39332d5 | ||
|
|
53c08d6807 | ||
|
|
7efe0a23b1 | ||
|
|
8dedc81512 | ||
|
|
5cbe9d4aa7 | ||
|
|
515fae9e25 | ||
|
|
0946d3921f | ||
|
|
1d40950118 | ||
|
|
b404d48ba6 | ||
|
|
2f1ba27d51 | ||
|
|
23124b36b7 | ||
|
|
3e8d05fa2e | ||
|
|
c36b582209 | ||
|
|
3c66ca7499 | ||
|
|
a042576652 | ||
|
|
3c120042a4 | ||
|
|
4e3603d5e4 | ||
|
|
f7ad970a83 | ||
|
|
4cc87633e1 | ||
|
|
2d565ad918 | ||
|
|
b4cb4d36f1 | ||
|
|
7fcef2a9cb | ||
|
|
e7dc0e0c5a | ||
|
|
3b2c7d2f73 | ||
|
|
209f926990 | ||
|
|
ef8b509ea5 | ||
|
|
06b5bdb6da | ||
|
|
a5a3efebcb | ||
|
|
cd39d8c8c0 | ||
|
|
1046bfec0f | ||
|
|
d8c83e25f4 | ||
|
|
a9fa614416 | ||
|
|
ecbbc23862 |
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -18,11 +17,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.1
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ patch-for-macos: backup
|
|||||||
# Build the macOS app
|
# Build the macOS app
|
||||||
build: #check generate
|
build: #check generate
|
||||||
@echo "Building BitChat for macOS..."
|
@echo "Building BitChat for macOS..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
|
|
||||||
# Run the macOS app
|
# Run the macOS app
|
||||||
run: build
|
run: build
|
||||||
|
|||||||
+4
-6
@@ -16,7 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -26,7 +26,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.product(name: "Tor", package: "Arti")
|
.product(name: "Tor", package: "Tor")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -34,8 +34,7 @@ let package = Package(
|
|||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"ViewModels/Extensions/README.md"
|
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localizable.xcstrings")
|
.process("Localizable.xcstrings")
|
||||||
@@ -50,8 +49,7 @@ let package = Package(
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization")
|
||||||
.process("Noise")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
|
|
||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
Generated
+7
-39
@@ -14,6 +14,7 @@
|
|||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -94,24 +95,6 @@
|
|||||||
);
|
);
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||||
};
|
};
|
||||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
Localization/PrimaryLocalizationKeys.json,
|
|
||||||
README.md,
|
|
||||||
);
|
|
||||||
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
|
|
||||||
};
|
|
||||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
Localization/PrimaryLocalizationKeys.json,
|
|
||||||
README.md,
|
|
||||||
);
|
|
||||||
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -135,10 +118,6 @@
|
|||||||
};
|
};
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
|
|
||||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
|
|
||||||
);
|
|
||||||
path = bitchatTests;
|
path = bitchatTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -161,6 +140,7 @@
|
|||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
@@ -233,7 +213,6 @@
|
|||||||
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
5C22AA7B9ACC5A861445C769 /* Sources */,
|
5C22AA7B9ACC5A861445C769 /* Sources */,
|
||||||
C5E027A42ECCDFD700BD6012 /* Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -266,7 +245,6 @@
|
|||||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
865C8403EF02C089369A9FCB /* Sources */,
|
865C8403EF02C089369A9FCB /* Sources */,
|
||||||
C5E027A72ECCDFE200BD6012 /* Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -343,7 +321,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -365,16 +343,6 @@
|
|||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
C5E027A72ECCDFE200BD6012 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
CD6E8F32BC38357473954F97 /* Resources */ = {
|
CD6E8F32BC38357473954F97 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
@@ -911,9 +879,9 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/Arti;
|
relativePath = localPackages/Tor;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
@@ -922,8 +890,8 @@
|
|||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = exactVersion;
|
kind = upToNextMajorVersion;
|
||||||
version = 0.21.1;
|
minimumVersion = 0.21.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -98,8 +98,8 @@
|
|||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "BITCHAT_LOG_LEVEL"
|
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
value = "debug"
|
value = ""
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
+13
-20
@@ -53,20 +53,17 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
let nickname = chatViewModel.nickname
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#elseif os(macOS)
|
||||||
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#endif
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
NetworkActivationService.shared.start()
|
NetworkActivationService.shared.start()
|
||||||
|
|
||||||
// Start presence service (will wait for Tor readiness)
|
|
||||||
GeohashPresenceService.shared.start()
|
|
||||||
|
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -192,10 +189,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
chatViewModel?.applicationWillTerminate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -253,15 +246,10 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
// Access main-actor-isolated property via Task
|
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
Task { @MainActor in
|
completionHandler([])
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
return
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
@@ -279,3 +267,8 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var nilIfEmpty: String? {
|
||||||
|
self.isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 60_000
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
@@ -38,7 +38,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -106,7 +106,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
@@ -58,20 +57,11 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
guard session.recordPermission == .granted else {
|
guard session.recordPermission == .granted else {
|
||||||
throw RecorderError.microphoneAccessDenied
|
throw RecorderError.microphoneAccessDenied
|
||||||
}
|
}
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord,
|
.playAndRecord,
|
||||||
mode: .default,
|
mode: .default,
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
)
|
)
|
||||||
#endif
|
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -85,14 +75,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 16_000,
|
AVSampleRateKey: 16_000,
|
||||||
AVNumberOfChannelsKey: 1,
|
AVNumberOfChannelsKey: 1,
|
||||||
AVEncoderBitRateKey: 16_000
|
AVEncoderBitRateKey: 20_000
|
||||||
]
|
]
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
audioRecorder.delegate = self
|
audioRecorder.delegate = self
|
||||||
audioRecorder.isMeteringEnabled = true
|
audioRecorder.isMeteringEnabled = true
|
||||||
audioRecorder.prepareToRecord()
|
audioRecorder.prepareToRecord()
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
audioRecorder.record()
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = outputURL
|
||||||
|
|||||||
@@ -6268,6 +6268,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Choose an image" : {
|
||||||
|
"comment" : "A label displayed above a button that allows the user to choose an image to send.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"close" : {
|
"close" : {
|
||||||
"comment" : "Button to dismiss fullscreen media viewer",
|
"comment" : "Button to dismiss fullscreen media viewer",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -35701,544 +35705,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Voice notes are only available in mesh chats." : {
|
|
||||||
"extractionState" : "manual",
|
|
||||||
"localizations" : {
|
|
||||||
"ar" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "الملاحظات الصوتية متاحة فقط في محادثات الميش."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bn" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ভয়েস নোট শুধু মেশ চ্যাটে উপলব্ধ।"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Sprachnachrichten sind nur im Mesh-Chat verfügbar."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Voice notes are only available in mesh chats."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Las notas de voz solo están disponibles en los chats de mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Ang mga voice note ay available lamang sa mga mesh chat."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Les notes vocales sont uniquement disponibles dans les discussions mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"he" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "הערות קוליות זמינות רק בצ׳אט של mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "वॉइस नोट्स केवल मेश चैट में ही उपलब्ध हैं।"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Catatan suara hanya tersedia di obrolan mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"it" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Le note vocali sono disponibili solo nelle chat mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ボイスメモはメッシュチャットでのみ利用できます。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "음성 메모는 메쉬 채팅에서만 사용할 수 있습니다."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Nota suara hanya tersedia dalam sembang mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ne" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "भ्वाइस नोटहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Spraaknotities zijn alleen beschikbaar in mesh-chats."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Notatki głosowe są dostępne tylko na czatach mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "As notas de voz só estão disponíveis nos chats mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt-BR" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "As mensagens de voz só estão disponíveis nos chats mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Голосовые сообщения доступны только в mesh-чатах."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Röstanteckningar är bara tillgängliga i mesh-chattar."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ta" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "குரல் குறிப்புகள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"th" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "บันทึกเสียงใช้งานได้เฉพาะในแชต mesh เท่านั้น"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Sesli notlar yalnızca mesh sohbetlerinde kullanılabilir."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uk" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Голосові нотатки доступні лише в mesh-чатах."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ur" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "وائس نوٹس صرف میش چیٹس میں دستیاب ہیں۔"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Ghi chú giọng nói chỉ khả dụng trong các cuộc trò chuyện mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hans" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "语音消息仅可在 mesh 聊天中使用。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hant" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "語音訊息僅能在 mesh 聊天中使用。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Images are only available in mesh chats." : {
|
|
||||||
"extractionState" : "manual",
|
|
||||||
"localizations" : {
|
|
||||||
"ar" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "الصور متاحة فقط في محادثات الميش."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bn" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Bilder sind nur im Mesh-Chat verfügbar."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Images are only available in mesh chats."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Las imágenes solo están disponibles en los chats de mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Ang mga larawan ay available lamang sa mga mesh chat."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Les images sont uniquement disponibles dans les discussions mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"he" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "תמונות זמינות רק בצ׳אט של mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Gambar hanya tersedia di obrolan mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"it" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Le immagini sono disponibili solo nelle chat mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "画像はメッシュチャットでのみ利用できます。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Imej hanya tersedia dalam sembang mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ne" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Obrazy są dostępne tylko na czatach mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "As imagens só estão disponíveis nos chats mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt-BR" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "As imagens só estão disponíveis nos chats mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Изображения доступны только в mesh-чатах."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Bilder är bara tillgängliga i mesh-chattar."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ta" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"th" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uk" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Зображення доступні лише в mesh-чатах."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ur" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hans" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "图片仅可在 mesh 聊天中使用。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hant" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "圖片僅能在 mesh 聊天中使用。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Choose an image" : {
|
|
||||||
"comment" : "A label displayed above a button that allows the user to choose an image to send.",
|
|
||||||
"extractionState" : "manual",
|
|
||||||
"localizations" : {
|
|
||||||
"ar" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "اختر صورة"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bn" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "একটি ছবি নির্বাচন করুন"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Bild auswählen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Choose an image"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Elige una imagen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Pumili ng larawan"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Choisir une image"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"he" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "בחר תמונה"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "एक चित्र चुनें"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Pilih gambar"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"it" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Scegli un’immagine"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "画像を選択"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "이미지를 선택하세요"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Pilih imej"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ne" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "एउटा तस्वीर चयन गर्नुहोस्"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Kies een afbeelding"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pl" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Wybierz obraz"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Escolher uma imagem"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt-BR" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Escolha uma imagem"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Выберите изображение"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Välj en bild"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ta" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"th" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "เลือกภาพ"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Bir görüntü seç"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uk" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Виберіть зображення"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ur" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "ایک تصویر منتخب کریں"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vi" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Chọn một hình ảnh"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hans" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "选择图像"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hant" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "選擇圖像"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ struct BitchatPacket: Codable {
|
|||||||
let payload: Data
|
let payload: Data
|
||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
var route: [Data]?
|
|
||||||
var isRSR: Bool
|
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -33,12 +31,10 @@ struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = signature
|
self.signature = signature
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = route
|
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
@@ -57,8 +53,6 @@ struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = nil
|
self.signature = nil
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = nil
|
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data? {
|
var data: Data? {
|
||||||
@@ -87,9 +81,7 @@ struct BitchatPacket: Codable {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
version: version,
|
version: version
|
||||||
route: route,
|
|
||||||
isRSR: false // RSR flag is mutable and not part of the signature
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// CommandsInfo.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - CommandInfo Enum
|
|
||||||
|
|
||||||
enum CommandInfo: String, Identifiable {
|
|
||||||
case block
|
|
||||||
case clear
|
|
||||||
case hug
|
|
||||||
case message = "dm"
|
|
||||||
case slap
|
|
||||||
case unblock
|
|
||||||
case who
|
|
||||||
case favorite
|
|
||||||
case unfavorite
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var alias: String { "/" + rawValue }
|
|
||||||
|
|
||||||
var placeholder: String? {
|
|
||||||
switch self {
|
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
|
||||||
case .clear, .who:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .block: String(localized: "content.commands.block")
|
|
||||||
case .clear: String(localized: "content.commands.clear")
|
|
||||||
case .hug: String(localized: "content.commands.hug")
|
|
||||||
case .message: String(localized: "content.commands.message")
|
|
||||||
case .slap: String(localized: "content.commands.slap")
|
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
|
||||||
case .who: String(localized: "content.commands.who")
|
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
|
||||||
if isGeoPublic || isGeoDM {
|
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
|
||||||
}
|
|
||||||
return baseCommands
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-22
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
|
|||||||
// Private so the callers have to go through a convenience init
|
// Private so the callers have to go through a convenience init
|
||||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
self.bare = String(bare).lowercased()
|
self.bare = String(bare)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +76,6 @@ extension PeerID {
|
|||||||
init(hexData: Data) {
|
init(hexData: Data) {
|
||||||
self.init(str: hexData.hexEncodedString())
|
self.init(str: hexData.hexEncodedString())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to "hide" hex-encoding implementation detail
|
|
||||||
init?(hexData: Data?) {
|
|
||||||
guard let hexData else { return nil }
|
|
||||||
self.init(hexData: hexData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noise Public Key Helpers
|
// MARK: - Noise Public Key Helpers
|
||||||
@@ -136,20 +130,6 @@ extension PeerID {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PeerID {
|
|
||||||
var routingData: Data? {
|
|
||||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
|
||||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
|
||||||
let short = toShort()
|
|
||||||
return Data(hexString: short.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(routingData: Data) {
|
|
||||||
guard routingData.count == 8 else { return nil }
|
|
||||||
self.init(hexData: routingData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
extension PeerID {
|
extension PeerID {
|
||||||
@@ -211,7 +191,9 @@ extension PeerID: Comparable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CustomStringConvertible
|
// MARK: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
extension PeerID: CustomStringConvertible {
|
extension PeerID: CustomStringConvertible {
|
||||||
/// So it returns the actual `id` like before even inside another String
|
/// So it returns the actual `id` like before even inside another String
|
||||||
@@ -219,3 +201,17 @@ extension PeerID: CustomStringConvertible {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import Foundation
|
|||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
let receiptID: String
|
let receiptID: String
|
||||||
var readerID: PeerID // Who read it
|
var readerID: String // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = UUID().uuidString
|
self.receiptID = UUID().uuidString
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For binary decoding
|
// For binary decoding
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = receiptID
|
self.receiptID = receiptID
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
|||||||
data.appendUUID(receiptID)
|
data.appendUUID(receiptID)
|
||||||
// ReaderID as 8-byte hex string
|
// ReaderID as 8-byte hex string
|
||||||
var readerData = Data()
|
var readerData = Data()
|
||||||
var tempID = readerID.id
|
var tempID = readerID
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
|||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = PeerID(hexData: readerIDData)
|
let readerID = readerIDData.hexEncodedString()
|
||||||
guard readerID.isValid else { return nil }
|
guard PeerID(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -8,18 +8,6 @@ struct RequestSyncPacket {
|
|||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
let types: SyncTypeFlags?
|
|
||||||
let sinceTimestamp: UInt64?
|
|
||||||
let fragmentIdFilter: String?
|
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
|
||||||
self.p = p
|
|
||||||
self.m = m
|
|
||||||
self.data = data
|
|
||||||
self.types = types
|
|
||||||
self.sinceTimestamp = sinceTimestamp
|
|
||||||
self.fragmentIdFilter = fragmentIdFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data {
|
func encode() -> Data {
|
||||||
var out = Data()
|
var out = Data()
|
||||||
@@ -37,16 +25,6 @@ struct RequestSyncPacket {
|
|||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
// data
|
// data
|
||||||
putTLV(0x03, data)
|
putTLV(0x03, data)
|
||||||
if let typesData = types?.toData() {
|
|
||||||
putTLV(0x04, typesData)
|
|
||||||
}
|
|
||||||
if let ts = sinceTimestamp {
|
|
||||||
var tsBE = ts.bigEndian
|
|
||||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
|
||||||
}
|
|
||||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
|
||||||
putTLV(0x06, fidData)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +33,6 @@ struct RequestSyncPacket {
|
|||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
var types: SyncTypeFlags? = nil
|
|
||||||
var sinceTimestamp: UInt64? = nil
|
|
||||||
var fragmentIdFilter: String? = nil
|
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -77,26 +52,12 @@ struct RequestSyncPacket {
|
|||||||
case 0x03:
|
case 0x03:
|
||||||
if v.count > maxAcceptBytes { return nil }
|
if v.count > maxAcceptBytes { return nil }
|
||||||
payload = v
|
payload = v
|
||||||
case 0x04:
|
|
||||||
if let decoded = SyncTypeFlags.decode(v) {
|
|
||||||
types = decoded
|
|
||||||
}
|
|
||||||
case 0x05:
|
|
||||||
if v.count == 8 {
|
|
||||||
var ts: UInt64 = 0
|
|
||||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
|
||||||
sinceTimestamp = ts
|
|
||||||
}
|
|
||||||
case 0x06:
|
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
|
||||||
fragmentIdFilter = fid
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,12 +165,8 @@ final class NoiseCipherState {
|
|||||||
// MARK: - Sliding Window Replay Protection
|
// MARK: - Sliding Window Replay Protection
|
||||||
|
|
||||||
/// Check if nonce is valid for replay protection
|
/// Check if nonce is valid for replay protection
|
||||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
|
||||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
|
||||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
|
||||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
|
||||||
return false // Too old, outside window
|
return false // Too old, outside window
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,19 +348,15 @@ final class NoiseCipherState {
|
|||||||
do {
|
do {
|
||||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||||
|
|
||||||
// BCH-01-010: Atomic nonce state update
|
|
||||||
// Both replay window marking and nonce increment must complete together
|
|
||||||
// to prevent state desynchronization. We perform both after successful
|
|
||||||
// decryption only, ensuring state consistency on any failure path.
|
|
||||||
if useExtractedNonce {
|
if useExtractedNonce {
|
||||||
|
// Mark nonce as seen after successful decryption
|
||||||
markNonceAsSeen(decryptionNonce)
|
markNonceAsSeen(decryptionNonce)
|
||||||
}
|
}
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
|
||||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
|
// Log authentication failures with nonce info
|
||||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -459,40 +451,17 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||||
|
|
||||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
|
||||||
// The chaining key and hash should not be retained after handshake completes
|
|
||||||
clearSensitiveData()
|
|
||||||
|
|
||||||
return (c1, c2)
|
return (c1, c2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
|
||||||
/// Called after split() to clear chaining key and hash per Noise spec
|
|
||||||
func clearSensitiveData() {
|
|
||||||
// Clear chaining key by overwriting with zeros
|
|
||||||
let chainingKeyCount = chainingKey.count
|
|
||||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
|
||||||
|
|
||||||
// Clear hash by overwriting with zeros
|
|
||||||
let hashCount = hash.count
|
|
||||||
hash = Data(repeating: 0, count: hashCount)
|
|
||||||
|
|
||||||
// Clear the internal cipher state
|
|
||||||
cipherState.clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF implementation
|
// HKDF implementation
|
||||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||||
@@ -538,24 +507,16 @@ final class NoiseHandshakeState {
|
|||||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||||
private var currentPattern = 0
|
private var currentPattern = 0
|
||||||
|
|
||||||
// Test support: predetermined ephemeral keys for test vectors
|
|
||||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
|
||||||
private var prologueData: Data
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
pattern: NoisePattern,
|
pattern: NoisePattern,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
prologue: Data = Data(),
|
|
||||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
|
||||||
) {
|
) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.prologueData = prologue
|
|
||||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
|
||||||
|
|
||||||
// Initialize static keys
|
// Initialize static keys
|
||||||
if let localKey = localStaticKey {
|
if let localKey = localStaticKey {
|
||||||
@@ -576,8 +537,8 @@ final class NoiseHandshakeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mixPreMessageKeys() {
|
private func mixPreMessageKeys() {
|
||||||
// Mix prologue
|
// Mix prologue (empty for XX pattern normally)
|
||||||
symmetricState.mixHash(self.prologueData)
|
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||||
// For XX pattern, no pre-message keys
|
// For XX pattern, no pre-message keys
|
||||||
// For IK/NK patterns, we'd mix the responder's static key here
|
// For IK/NK patterns, we'd mix the responder's static key here
|
||||||
switch pattern {
|
switch pattern {
|
||||||
@@ -602,13 +563,8 @@ final class NoiseHandshakeState {
|
|||||||
for pattern in patterns {
|
for pattern in patterns {
|
||||||
switch pattern {
|
switch pattern {
|
||||||
case .e:
|
case .e:
|
||||||
// Generate ephemeral key (or use predetermined key for tests)
|
// Generate ephemeral key
|
||||||
if let predetermined = predeterminedEphemeralKey {
|
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||||
localEphemeralPrivate = predetermined
|
|
||||||
predeterminedEphemeralKey = nil
|
|
||||||
} else {
|
|
||||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
|
||||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||||
@@ -641,20 +597,14 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
} else {
|
} else {
|
||||||
guard let localStatic = localStaticPrivate,
|
guard let localStatic = localStaticPrivate,
|
||||||
let remoteEphemeral = remoteEphemeralPublic else {
|
let remoteEphemeral = remoteEphemeralPublic else {
|
||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .se:
|
case .se:
|
||||||
@@ -665,20 +615,14 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
} else {
|
} else {
|
||||||
guard let localEphemeral = localEphemeralPrivate,
|
guard let localEphemeral = localEphemeralPrivate,
|
||||||
let remoteStatic = remoteStaticPublic else {
|
let remoteStatic = remoteStaticPublic else {
|
||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .ss:
|
case .ss:
|
||||||
@@ -767,10 +711,7 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
|
|
||||||
case .es:
|
case .es:
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
@@ -824,10 +765,7 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
|
|
||||||
case .e, .s:
|
case .e, .s:
|
||||||
break
|
break
|
||||||
@@ -838,20 +776,16 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
return currentPattern >= messagePatterns.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||||
guard isHandshakeComplete() else {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
let (c1, c2) = symmetricState.split()
|
||||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
@@ -912,44 +846,19 @@ enum NoiseError: Error {
|
|||||||
case nonceExceeded
|
case nonceExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constant-Time Operations
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
|
||||||
/// This function compares two Data objects in constant time, preventing
|
|
||||||
/// information leakage via timing analysis.
|
|
||||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
|
||||||
guard a.count == b.count else { return false }
|
|
||||||
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for i in 0..<a.count {
|
|
||||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
|
||||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for byte in data {
|
|
||||||
result |= byte
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Validation
|
// MARK: - Key Validation
|
||||||
|
|
||||||
extension NoiseHandshakeState {
|
extension NoiseHandshakeState {
|
||||||
/// Validate a Curve25519 public key
|
/// Validate a Curve25519 public key
|
||||||
/// Checks for weak/invalid keys that could compromise security
|
/// Checks for weak/invalid keys that could compromise security
|
||||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
|
||||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
// Check key length
|
// Check key length
|
||||||
guard keyData.count == 32 else {
|
guard keyData.count == 32 else {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
// Check for all-zero key (point at infinity)
|
||||||
if constantTimeIsZero(keyData) {
|
if keyData.allSatisfy({ $0 == 0 }) {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,16 +883,8 @@ extension NoiseHandshakeState {
|
|||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||||
]
|
]
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check against known bad points
|
// Check against known bad points
|
||||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
if lowOrderPoints.contains(keyData) {
|
||||||
var foundBadPoint = false
|
|
||||||
for badPoint in lowOrderPoints {
|
|
||||||
if constantTimeCompare(keyData, badPoint) {
|
|
||||||
foundBadPoint = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundBadPoint {
|
|
||||||
SecureLogger.warning("Low-order point detected", category: .security)
|
SecureLogger.warning("Low-order point detected", category: .security)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ class NoiseSession {
|
|||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
@@ -128,8 +128,8 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ class NoiseSession {
|
|||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
import Tor
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -17,32 +12,19 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||||
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var retryTask: Task<Void, Never>?
|
|
||||||
private var retryAttempt: Int = 0
|
|
||||||
private var isFetching: Bool = false
|
|
||||||
private var observers: [NSObjectProtocol] = []
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
entries = loadLocalEntries()
|
// Load cached or bundled data synchronously
|
||||||
registerObservers()
|
self.entries = self.loadLocalEntries()
|
||||||
startRefreshTimer()
|
// Fire-and-forget remote refresh if stale
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
retryTask?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
let center = Geohash.decodeCenter(geohash)
|
let center = Geohash.decodeCenter(geohash)
|
||||||
@@ -80,119 +62,42 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded() {
|
||||||
guard !isFetching else { return }
|
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
if !force {
|
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
|
||||||
} else if last != .distantPast,
|
|
||||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
guard !isFetching else { return }
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
isFetching = true
|
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||||
|
Task.detached {
|
||||||
let request = URLRequest(
|
|
||||||
url: remoteURL,
|
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
|
||||||
timeoutInterval: 15
|
|
||||||
)
|
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
if !ready {
|
||||||
await self.handleFetchFailure(.torNotReady)
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||||
do {
|
guard let self = self else { return }
|
||||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
await self.handleFetchFailure(.invalidData)
|
if !parsed.isEmpty {
|
||||||
return
|
Task { @MainActor in
|
||||||
|
self.entries = parsed
|
||||||
|
self.persistCache(text)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
|
||||||
guard !parsed.isEmpty else {
|
|
||||||
await self.handleFetchFailure(.invalidData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
await self.handleFetchFailure(.network(error))
|
|
||||||
}
|
}
|
||||||
|
task.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum FetchFailure {
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
|
||||||
entries = parsed
|
|
||||||
persistCache(csv)
|
|
||||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
||||||
isFetching = false
|
|
||||||
retryAttempt = 0
|
|
||||||
cancelRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
|
||||||
switch reason {
|
|
||||||
case .torNotReady:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
|
||||||
case .invalidData:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
|
||||||
case .network(let error):
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
|
||||||
}
|
|
||||||
isFetching = false
|
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func scheduleRetry() {
|
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
|
||||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
|
||||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
|
||||||
let calculated = base * multiplier
|
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
retryTask = Task { [weak self] in
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
await MainActor.run {
|
|
||||||
self?.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func cancelRetry() {
|
|
||||||
retryTask?.cancel()
|
|
||||||
retryTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
do {
|
do {
|
||||||
@@ -205,35 +110,30 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = cacheURL(),
|
if let cache = self.cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = [
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -241,6 +141,7 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.isEmpty { continue }
|
if line.isEmpty { continue }
|
||||||
@@ -261,76 +162,11 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
private func cacheURL() -> URL? {
|
private func cacheURL() -> URL? {
|
||||||
do {
|
do {
|
||||||
let base = try FileManager.default.url(
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
} catch {
|
} catch { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
|
||||||
private func registerObservers() {
|
|
||||||
let center = NotificationCenter.default
|
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
|
||||||
forName: .TorDidBecomeReady,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(torReady)
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(didBecomeActive)
|
|
||||||
#elseif os(macOS)
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: NSApplication.didBecomeActiveNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(didBecomeActive)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
|
||||||
guard interval > 0 else { return }
|
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshTimer = timer
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
|
func load(key: String, service: String) -> Data?
|
||||||
|
func delete(key: String, service: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper: KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerID(publicKey: maybeData)
|
return PeerID(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ final class NostrIdentityBridge {
|
|||||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||||
private let cacheLock = NSLock()
|
private let cacheLock = NSLock()
|
||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -126,24 +125,6 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
|
||||||
/// Must contain empty content and NO nickname tag
|
|
||||||
static func createGeohashPresenceEvent(
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let tags = [["g", geohash]]
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: tags,
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||||
|
|
||||||
|
// Reconnection timer
|
||||||
|
private var reconnectionTimer: Timer?
|
||||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
@@ -884,10 +887,10 @@ struct NostrFilter: Encodable {
|
|||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
var filter = NostrFilter()
|
var filter = NostrFilter()
|
||||||
filter.kinds = [20000, 20001]
|
filter.kinds = [20000]
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["g": [geohash]]
|
filter.tagFilters = ["g": [geohash]]
|
||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
|
|||||||
@@ -5,27 +5,16 @@ import CryptoKit
|
|||||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
/// as per XChaCha20 construction.
|
/// as per XChaCha20 construction.
|
||||||
enum XChaCha20Poly1305Compat {
|
enum XChaCha20Poly1305Compat {
|
||||||
|
|
||||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
|
||||||
enum Error: Swift.Error {
|
|
||||||
case invalidKeyLength(expected: Int, got: Int)
|
|
||||||
case invalidNonceLength(expected: Int, got: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SealBox {
|
struct SealBox {
|
||||||
let ciphertext: Data
|
let ciphertext: Data
|
||||||
let tag: Data
|
let tag: Data
|
||||||
}
|
}
|
||||||
|
|
||||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
@@ -34,14 +23,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||||
@@ -58,14 +43,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32)
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce16.count == 16)
|
||||||
}
|
|
||||||
guard nonce16.count == 16 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants "expand 32-byte k"
|
// Constants "expand 32-byte k"
|
||||||
var state: [UInt32] = [
|
var state: [UInt32] = [
|
||||||
|
|||||||
@@ -23,36 +23,14 @@ extension Data {
|
|||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize Data from a hex string.
|
|
||||||
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
|
|
||||||
/// Whitespace is trimmed. Must have even length after prefix removal.
|
|
||||||
/// - Returns: nil if the string has odd length or contains invalid hex characters.
|
|
||||||
init?(hexString: String) {
|
init?(hexString: String) {
|
||||||
var hex = hexString.trimmingCharacters(in: .whitespaces)
|
let len = hexString.count / 2
|
||||||
|
|
||||||
// Remove optional 0x prefix
|
|
||||||
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
|
|
||||||
hex = String(hex.dropFirst(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject odd-length strings
|
|
||||||
guard hex.count % 2 == 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject empty strings
|
|
||||||
guard !hex.isEmpty else {
|
|
||||||
self = Data()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let len = hex.count / 2
|
|
||||||
var data = Data(capacity: len)
|
var data = Data(capacity: len)
|
||||||
var index = hex.startIndex
|
var index = hexString.startIndex
|
||||||
|
|
||||||
for _ in 0..<len {
|
for _ in 0..<len {
|
||||||
let nextIndex = hex.index(index, offsetBy: 2)
|
let nextIndex = hexString.index(index, offsetBy: 2)
|
||||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data.append(byte)
|
data.append(byte)
|
||||||
|
|||||||
@@ -137,8 +137,6 @@ struct BinaryProtocol {
|
|||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
static let hasRoute: UInt8 = 0x08
|
|
||||||
static let isRSR: UInt8 = 0x10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
@@ -162,30 +160,14 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
|
|
||||||
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
|
|
||||||
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
|
|
||||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
|
||||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
|
||||||
if hop.count == senderIDSize { return hop }
|
|
||||||
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
|
|
||||||
var padded = hop
|
|
||||||
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
|
|
||||||
return padded
|
|
||||||
}
|
|
||||||
guard sanitizedRoute.count <= 255 else { return nil }
|
|
||||||
|
|
||||||
let hasRoute = !sanitizedRoute.isEmpty
|
|
||||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
|
||||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// payloadLength in header is payload-only (does NOT include route bytes)
|
|
||||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||||
|
|
||||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
|
|
||||||
guard let headerSize = headerSize(for: version) else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
let estimatedPayload = payloadDataSize
|
let estimatedPayload = payloadDataSize
|
||||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
var data = Data()
|
var data = Data()
|
||||||
@@ -203,9 +185,6 @@ struct BinaryProtocol {
|
|||||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
if isCompressed { flags |= Flags.isCompressed }
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
// HAS_ROUTE is only valid for v2+ packets
|
|
||||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
|
||||||
if packet.isRSR { flags |= Flags.isRSR }
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
@@ -233,13 +212,6 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
data.append(UInt8(sanitizedRoute.count))
|
|
||||||
for hop in sanitizedRoute {
|
|
||||||
data.append(hop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
let value = UInt32(originalSize)
|
let value = UInt32(originalSize)
|
||||||
@@ -329,9 +301,6 @@ struct BinaryProtocol {
|
|||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
|
||||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
|
||||||
let isRSR = (flags & Flags.isRSR) != 0
|
|
||||||
|
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
@@ -352,21 +321,6 @@ struct BinaryProtocol {
|
|||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
|
|
||||||
var route: [Data]? = nil
|
|
||||||
if hasRoute {
|
|
||||||
guard let routeCount = read8() else { return nil }
|
|
||||||
if routeCount > 0 {
|
|
||||||
var hops: [Data] = []
|
|
||||||
for _ in 0..<Int(routeCount) {
|
|
||||||
guard let hop = readData(senderIDSize) else { return nil }
|
|
||||||
hops.append(hop)
|
|
||||||
}
|
|
||||||
route = hops
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||||
@@ -378,10 +332,16 @@ struct BinaryProtocol {
|
|||||||
guard let rawSize = read16() else { return nil }
|
guard let rawSize = read16() else { return nil }
|
||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
|
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
|
||||||
|
// Validate compression ratio to prevent zip bomb attacks
|
||||||
|
// Primary protection: originalSize capped at 1MB (line 336)
|
||||||
|
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||||
|
guard compressedSize > 0 else { return nil }
|
||||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
guard compressionRatio <= 50_000.0 else {
|
guard compressionRatio <= 50_000.0 else {
|
||||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
@@ -412,9 +372,7 @@ struct BinaryProtocol {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
version: version,
|
version: version
|
||||||
route: route,
|
|
||||||
isRSR: isRSR
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -195,7 +195,7 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,18 +116,4 @@ enum ChannelID: Equatable, Codable {
|
|||||||
case .location(let ch): return ch.geohash
|
case .location(let ch): return ch.geohash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isMesh: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: true
|
|
||||||
case .location: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isLocation: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: false
|
|
||||||
case .location: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ struct AnnouncementPacket {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -38,16 +36,6 @@ struct AnnouncementPacket {
|
|||||||
data.append(UInt8(signingPublicKey.count))
|
data.append(UInt8(signingPublicKey.count))
|
||||||
data.append(signingPublicKey)
|
data.append(signingPublicKey)
|
||||||
|
|
||||||
// TLV for direct neighbors (optional)
|
|
||||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
|
||||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
|
||||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
|
||||||
data.append(TLVType.directNeighbors.rawValue)
|
|
||||||
data.append(UInt8(neighborsData.count))
|
|
||||||
data.append(neighborsData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +44,6 @@ struct AnnouncementPacket {
|
|||||||
var nickname: String?
|
var nickname: String?
|
||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -76,17 +63,6 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey = Data(value)
|
noisePublicKey = Data(value)
|
||||||
case .signingPublicKey:
|
case .signingPublicKey:
|
||||||
signingPublicKey = Data(value)
|
signingPublicKey = Data(value)
|
||||||
case .directNeighbors:
|
|
||||||
if length > 0 && length % 8 == 0 {
|
|
||||||
var neighbors = [Data]()
|
|
||||||
let count = length / 8
|
|
||||||
for i in 0..<count {
|
|
||||||
let start = value.startIndex + i * 8
|
|
||||||
let end = start + 8
|
|
||||||
neighbors.append(Data(value[start..<end]))
|
|
||||||
}
|
|
||||||
directNeighbors = neighbors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -98,8 +74,7 @@ struct AnnouncementPacket {
|
|||||||
return AnnouncementPacket(
|
return AnnouncementPacket(
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey
|
||||||
directNeighbors: directNeighbors
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
//
|
|
||||||
// MimeType.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
// MARK: - Extensions for missing UTTypes
|
|
||||||
|
|
||||||
extension UTType {
|
|
||||||
static let webP = UTType(importedAs: "image/webp")
|
|
||||||
static let aac = UTType(importedAs: "audio/aac")
|
|
||||||
static let m4a = UTType(importedAs: "audio/m4a")
|
|
||||||
static let ogg = UTType(importedAs: "audio/ogg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MimeType Enum
|
|
||||||
|
|
||||||
enum MimeType: CaseIterable, Hashable {
|
|
||||||
case jpeg
|
|
||||||
case jpg
|
|
||||||
case png
|
|
||||||
case gif
|
|
||||||
case webp
|
|
||||||
case mp4Audio
|
|
||||||
case m4a
|
|
||||||
case aac
|
|
||||||
case mpeg
|
|
||||||
case mp3
|
|
||||||
case wav
|
|
||||||
case xWav
|
|
||||||
case ogg
|
|
||||||
case pdf
|
|
||||||
case octetStream
|
|
||||||
|
|
||||||
var utType: UTType {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: .jpeg
|
|
||||||
case .png: .png
|
|
||||||
case .gif: .gif
|
|
||||||
case .webp: .webP
|
|
||||||
case .aac: .aac
|
|
||||||
case .m4a: .m4a
|
|
||||||
case .mp4Audio: .mpeg4Audio
|
|
||||||
case .mp3, .mpeg: .mp3
|
|
||||||
case .wav, .xWav: .wav
|
|
||||||
case .ogg: .ogg
|
|
||||||
case .pdf: .pdf
|
|
||||||
case .octetStream: .data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var category: Category {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg, .png, .gif, .webp:
|
|
||||||
return .image
|
|
||||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
|
||||||
return .audio
|
|
||||||
case .pdf, .octetStream:
|
|
||||||
return .file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var mimeString: String {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: "image/jpeg"
|
|
||||||
case .png: "image/png"
|
|
||||||
case .gif: "image/gif"
|
|
||||||
case .webp: "image/webp"
|
|
||||||
case .mp4Audio: "audio/mp4"
|
|
||||||
case .m4a: "audio/m4a"
|
|
||||||
case .aac: "audio/aac"
|
|
||||||
case .mpeg: "audio/mpeg"
|
|
||||||
case .mp3: "audio/mp3"
|
|
||||||
case .wav: "audio/wav"
|
|
||||||
case .xWav: "audio/x-wav"
|
|
||||||
case .ogg: "audio/ogg"
|
|
||||||
case .pdf: "application/pdf"
|
|
||||||
case .octetStream: "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultExtension: String {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: "jpg"
|
|
||||||
case .png: "png"
|
|
||||||
case .webp: "webp"
|
|
||||||
case .gif: "gif"
|
|
||||||
case .mp4Audio, .m4a, .aac: "m4a"
|
|
||||||
case .mpeg, .mp3: "mp3"
|
|
||||||
case .wav, .xWav: "wav"
|
|
||||||
case .ogg: "ogg"
|
|
||||||
case .pdf: "pdf"
|
|
||||||
case .octetStream: "bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static var allowed: Set<MimeType> = [
|
|
||||||
.jpeg, .jpg, .png, .gif, .webp,
|
|
||||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
|
||||||
.wav, .xWav, .ogg,
|
|
||||||
.pdf, .octetStream
|
|
||||||
]
|
|
||||||
|
|
||||||
var isAllowed: Bool {
|
|
||||||
Self.allowed.contains(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Byte signature validation
|
|
||||||
func matches(data: Data) -> Bool {
|
|
||||||
guard !data.isEmpty else { return false }
|
|
||||||
|
|
||||||
// Generic type → skip validation
|
|
||||||
if self == .octetStream { return true }
|
|
||||||
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg:
|
|
||||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
|
||||||
|
|
||||||
case .png:
|
|
||||||
return data.count >= 8 &&
|
|
||||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
|
||||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
|
||||||
|
|
||||||
case .gif:
|
|
||||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
||||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
|
||||||
|
|
||||||
case .webp:
|
|
||||||
return data.count >= 12 &&
|
|
||||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
|
||||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
|
||||||
|
|
||||||
case .m4a, .mp4Audio, .aac:
|
|
||||||
// AVAudioRecorder output varies by platform - be lenient
|
|
||||||
// Security: size already capped + sandboxed execution
|
|
||||||
return data.count > 100
|
|
||||||
|
|
||||||
case .mpeg, .mp3:
|
|
||||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
|
||||||
return true // ID3 header
|
|
||||||
}
|
|
||||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
|
||||||
|
|
||||||
case .wav, .xWav:
|
|
||||||
return data.count >= 12 &&
|
|
||||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
|
||||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
|
||||||
|
|
||||||
case .ogg:
|
|
||||||
return data.count >= 4 &&
|
|
||||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
|
||||||
|
|
||||||
case .pdf:
|
|
||||||
return data.count >= 4 &&
|
|
||||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Initializers
|
|
||||||
|
|
||||||
init?(_ mimeString: String?) {
|
|
||||||
guard let mimeString else { return nil }
|
|
||||||
|
|
||||||
let normalized = mimeString.lowercased()
|
|
||||||
|
|
||||||
// Direct match with our canonical list
|
|
||||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
|
||||||
self = match
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
|
||||||
if let type = UTType(mimeType: normalized),
|
|
||||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
|
||||||
self = match
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MimeType {
|
|
||||||
enum Category: String {
|
|
||||||
case audio, image, file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,52 +15,15 @@ enum CommandResult {
|
|||||||
case handled // Command handled, no message needed
|
case handled // Command handled, no message needed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple struct for geo participant info used by CommandProcessor
|
|
||||||
struct CommandGeoParticipant {
|
|
||||||
let id: String // pubkey hex (lowercased)
|
|
||||||
let displayName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol defining what CommandProcessor needs from its context.
|
|
||||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
|
||||||
@MainActor
|
|
||||||
protocol CommandContextProvider: AnyObject {
|
|
||||||
// MARK: - State Properties
|
|
||||||
var nickname: String { get }
|
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
|
||||||
var blockedUsers: Set<String> { get }
|
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
|
||||||
var idBridge: NostrIdentityBridge { get }
|
|
||||||
|
|
||||||
// MARK: - Peer Lookup
|
|
||||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
|
||||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
|
||||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
|
||||||
|
|
||||||
// MARK: - Chat Actions
|
|
||||||
func startPrivateChat(with peerID: PeerID)
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
|
||||||
func clearCurrentPublicTimeline()
|
|
||||||
func sendPublicRaw(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - System Messages
|
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
|
||||||
func addPublicSystemMessage(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - Favorites
|
|
||||||
func toggleFavorite(peerID: PeerID)
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CommandProcessor {
|
final class CommandProcessor {
|
||||||
weak var contextProvider: CommandContextProvider?
|
weak var chatViewModel: ChatViewModel?
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
self.contextProvider = contextProvider
|
self.chatViewModel = chatViewModel
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
}
|
}
|
||||||
@@ -79,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
|
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -118,15 +81,15 @@ final class CommandProcessor {
|
|||||||
let targetName = String(parts[0])
|
let targetName = String(parts[0])
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||||
return .error(message: "'\(nickname)' not found")
|
return .error(message: "'\(nickname)' not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
contextProvider?.startPrivateChat(with: peerID)
|
chatViewModel?.startPrivateChat(with: peerID)
|
||||||
|
|
||||||
if parts.count > 1 {
|
if parts.count > 1 {
|
||||||
let message = String(parts[1])
|
let message = String(parts[1])
|
||||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(message: "started private chat with \(nickname)")
|
return .success(message: "started private chat with \(nickname)")
|
||||||
@@ -137,9 +100,9 @@ final class CommandProcessor {
|
|||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -157,10 +120,10 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleClear() -> CommandResult {
|
private func handleClear() -> CommandResult {
|
||||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||||
contextProvider?.privateChats[peerID]?.removeAll()
|
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||||
} else {
|
} else {
|
||||||
contextProvider?.clearCurrentPublicTimeline()
|
chatViewModel?.clearCurrentPublicTimeline()
|
||||||
}
|
}
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -173,14 +136,14 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let myNickname = contextProvider?.nickname else {
|
let myNickname = chatViewModel?.nickname else {
|
||||||
return .error(message: "cannot \(command) \(nickname): not found")
|
return .error(message: "cannot \(command) \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||||
|
|
||||||
if contextProvider?.selectedPrivateChatPeer != nil {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
@@ -196,13 +159,13 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||||
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// In public chat: send to active public channel (mesh or geohash)
|
// In public chat: send to active public channel (mesh or geohash)
|
||||||
contextProvider?.sendPublicRaw(emoteContent)
|
chatViewModel?.sendPublicRaw(emoteContent)
|
||||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||||
contextProvider?.addPublicSystemMessage(publicEcho)
|
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .handled
|
return .handled
|
||||||
@@ -213,7 +176,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
let meshBlocked = contextProvider?.blockedUsers ?? []
|
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||||
var blockedNicknames: [String] = []
|
var blockedNicknames: [String] = []
|
||||||
if let peers = meshService?.getPeerNicknames() {
|
if let peers = meshService?.getPeerNicknames() {
|
||||||
for (peerID, nickname) in peers {
|
for (peerID, nickname) in peers {
|
||||||
@@ -227,8 +190,8 @@ final class CommandProcessor {
|
|||||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||||
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = contextProvider {
|
if let vm = chatViewModel {
|
||||||
let visible = vm.getVisibleGeoParticipants()
|
let visible = vm.visibleGeohashPeople()
|
||||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
for pk in geoBlocked {
|
for pk in geoBlocked {
|
||||||
if let name = visibleIndex[pk.lowercased()] {
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
@@ -247,7 +210,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
@@ -272,7 +235,7 @@ final class CommandProcessor {
|
|||||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||||
}
|
}
|
||||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
@@ -291,7 +254,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
@@ -300,7 +263,7 @@ final class CommandProcessor {
|
|||||||
return .success(message: "unblocked \(nickname)")
|
return .success(message: "unblocked \(nickname)")
|
||||||
}
|
}
|
||||||
// Try geohash unblock
|
// Try geohash unblock
|
||||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
@@ -318,7 +281,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
@@ -331,15 +294,15 @@ final class CommandProcessor {
|
|||||||
peerNickname: nickname
|
peerNickname: nickname
|
||||||
)
|
)
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||||
|
|
||||||
return .success(message: "added \(nickname) to favorites")
|
return .success(message: "added \(nickname) to favorites")
|
||||||
} else {
|
} else {
|
||||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||||
|
|
||||||
return .success(message: "removed \(nickname) from favorites")
|
return .success(message: "removed \(nickname) from favorites")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
|
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
|
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||||
|
final class GeohashBookmarksStore: ObservableObject {
|
||||||
|
static let shared = GeohashBookmarksStore()
|
||||||
|
|
||||||
|
@Published private(set) var bookmarks: [String] = []
|
||||||
|
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||||
|
|
||||||
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
|
private var membership: Set<String> = []
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var resolving: Set<String> = []
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
|
return membership.contains(Self.normalize(geohash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
if membership.contains(gh) {
|
||||||
|
remove(gh)
|
||||||
|
} else {
|
||||||
|
add(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard !gh.isEmpty else { return }
|
||||||
|
guard !membership.contains(gh) else { return }
|
||||||
|
bookmarks.insert(gh, at: 0)
|
||||||
|
membership.insert(gh)
|
||||||
|
persist()
|
||||||
|
// Resolve and persist a friendly name once when added
|
||||||
|
resolveNameIfNeeded(for: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard membership.contains(gh) else { return }
|
||||||
|
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||||
|
membership.remove(gh)
|
||||||
|
// Clean up stored name to avoid stale cache growth
|
||||||
|
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||||
|
persistNames()
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
private func load() {
|
||||||
|
guard let data = storage.data(forKey: storeKey) else { return }
|
||||||
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
|
var seen = Set<String>()
|
||||||
|
var list: [String] = []
|
||||||
|
for raw in arr {
|
||||||
|
let gh = Self.normalize(raw)
|
||||||
|
guard !gh.isEmpty else { continue }
|
||||||
|
if !seen.contains(gh) {
|
||||||
|
seen.insert(gh)
|
||||||
|
list.append(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookmarks = list
|
||||||
|
membership = seen
|
||||||
|
}
|
||||||
|
// Load any saved names
|
||||||
|
if let namesData = storage.data(forKey: namesStoreKey),
|
||||||
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
|
bookmarkNames = dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
|
storage.set(data, forKey: storeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistNames() {
|
||||||
|
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||||
|
storage.set(data, forKey: namesStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private static func normalize(_ s: String) -> String {
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
return s
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: "#", with: "")
|
||||||
|
.filter { allowed.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Name Resolution
|
||||||
|
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||||
|
func resolveNameIfNeeded(for geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard !gh.isEmpty else { return }
|
||||||
|
if bookmarkNames[gh] != nil { return }
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
if resolving.contains(gh) { return }
|
||||||
|
resolving.insert(gh)
|
||||||
|
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||||
|
if gh.count <= 2 {
|
||||||
|
let b = Geohash.decodeBounds(gh)
|
||||||
|
let pts: [CLLocation] = [
|
||||||
|
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||||
|
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||||
|
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||||
|
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||||
|
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||||
|
]
|
||||||
|
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||||
|
} else {
|
||||||
|
let center = Geohash.decodeCenter(gh)
|
||||||
|
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||||
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
defer { self.resolving.remove(gh) }
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||||
|
if let name = name, !name.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bookmarkNames[gh] = name
|
||||||
|
self.persistNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
|
var idx = 0
|
||||||
|
func step() {
|
||||||
|
if idx >= points.count {
|
||||||
|
// Compose up to 2 names joined by ' and '
|
||||||
|
let finalName: String? = {
|
||||||
|
let names = uniqueAdmins.array
|
||||||
|
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||||
|
return names.first
|
||||||
|
}()
|
||||||
|
if let finalName = finalName, !finalName.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bookmarkNames[gh] = finalName
|
||||||
|
self.persistNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.resolving.remove(gh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let loc = points[idx]
|
||||||
|
idx += 1
|
||||||
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
|
guard self != nil else { return }
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
uniqueAdmins.insert(admin)
|
||||||
|
} else if let country = pm.country, !country.isEmpty {
|
||||||
|
uniqueAdmins.insert(country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Proceed to next point
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal ordered-set for stable joining
|
||||||
|
private struct OrderedSet<Element: Hashable> {
|
||||||
|
private var set: Set<Element> = []
|
||||||
|
private(set) var array: [Element] = []
|
||||||
|
mutating func insert(_ element: Element) {
|
||||||
|
if set.insert(element).inserted { array.append(element) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||||
|
switch len {
|
||||||
|
case 0...2:
|
||||||
|
// Prefer administrative area if available at this coarse level
|
||||||
|
return pm.administrativeArea ?? pm.country
|
||||||
|
case 3...4:
|
||||||
|
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||||
|
case 5:
|
||||||
|
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||||
|
case 6...7:
|
||||||
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||||
|
default:
|
||||||
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashParticipantTracker.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Tracks participants in geohash-based location channels.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents a participant in a geohash channel
|
|
||||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
|
||||||
public let id: String // pubkey hex (lowercased)
|
|
||||||
public let displayName: String
|
|
||||||
public let lastSeen: Date
|
|
||||||
|
|
||||||
public init(id: String, displayName: String, lastSeen: Date) {
|
|
||||||
self.id = id
|
|
||||||
self.displayName = displayName
|
|
||||||
self.lastSeen = lastSeen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol for resolving display names and checking block status
|
|
||||||
@MainActor
|
|
||||||
public protocol GeohashParticipantContext: AnyObject {
|
|
||||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
|
||||||
/// Returns true if the pubkey is blocked
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks participants across multiple geohash channels
|
|
||||||
@MainActor
|
|
||||||
public final class GeohashParticipantTracker: ObservableObject {
|
|
||||||
|
|
||||||
/// Activity cutoff duration (defaults to 5 minutes)
|
|
||||||
public let activityCutoff: TimeInterval
|
|
||||||
|
|
||||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
|
||||||
private var participants: [String: [String: Date]] = [:]
|
|
||||||
|
|
||||||
/// Currently visible people for the active geohash
|
|
||||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
|
||||||
|
|
||||||
/// The currently active geohash (if any)
|
|
||||||
private var activeGeohash: String?
|
|
||||||
|
|
||||||
/// Context for display name resolution and block checking
|
|
||||||
private weak var context: GeohashParticipantContext?
|
|
||||||
|
|
||||||
/// Timer for periodic refresh
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
|
|
||||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
|
||||||
self.activityCutoff = activityCutoff
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure with a context provider
|
|
||||||
public func configure(context: GeohashParticipantContext) {
|
|
||||||
self.context = context
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the currently active geohash
|
|
||||||
public func setActiveGeohash(_ geohash: String?) {
|
|
||||||
activeGeohash = geohash
|
|
||||||
if geohash == nil {
|
|
||||||
visiblePeople = []
|
|
||||||
} else {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record activity from a participant in the current active geohash
|
|
||||||
public func recordParticipant(pubkeyHex: String) {
|
|
||||||
guard let gh = activeGeohash else { return }
|
|
||||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record activity from a participant in a specific geohash
|
|
||||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
var map = participants[geohash] ?? [:]
|
|
||||||
map[key] = Date()
|
|
||||||
participants[geohash] = map
|
|
||||||
|
|
||||||
// Always notify observers that state has changed so counts in UI update
|
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
// Only refresh visible list if this geohash is currently active
|
|
||||||
if activeGeohash == geohash {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a participant from all geohashes (used when blocking)
|
|
||||||
public func removeParticipant(pubkeyHex: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
for (gh, var map) in participants {
|
|
||||||
map.removeValue(forKey: key)
|
|
||||||
participants[gh] = map
|
|
||||||
}
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get participant count for a specific geohash
|
|
||||||
public func participantCount(for geohash: String) -> Int {
|
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
|
||||||
let map = participants[geohash] ?? [:]
|
|
||||||
return map.values.filter { $0 >= cutoff }.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the visible people list for the active geohash (read-only query)
|
|
||||||
public func getVisiblePeople() -> [GeoPerson] {
|
|
||||||
guard let gh = activeGeohash, let context = context else { return [] }
|
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
|
||||||
let map = (participants[gh] ?? [:])
|
|
||||||
.filter { $0.value >= cutoff }
|
|
||||||
.filter { !context.isBlocked($0.key) }
|
|
||||||
|
|
||||||
return map
|
|
||||||
.map { (pub, seen) in
|
|
||||||
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
|
|
||||||
}
|
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh the visible people list
|
|
||||||
public func refresh() {
|
|
||||||
visiblePeople = getVisiblePeople()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the periodic refresh timer
|
|
||||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
|
||||||
stopRefreshTimer()
|
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the periodic refresh timer
|
|
||||||
public func stopRefreshTimer() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all participant data
|
|
||||||
public func clear() {
|
|
||||||
participants.removeAll()
|
|
||||||
visiblePeople = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear participant data for a specific geohash
|
|
||||||
public func clear(geohash: String) {
|
|
||||||
participants.removeValue(forKey: geohash)
|
|
||||||
if activeGeohash == geohash {
|
|
||||||
visiblePeople = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashPresenceService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
|
||||||
// to geohash location channels.
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
|
||||||
///
|
|
||||||
/// Behavior:
|
|
||||||
/// - Monitors location changes via LocationStateManager
|
|
||||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
|
||||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
|
||||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
|
||||||
@MainActor
|
|
||||||
final class GeohashPresenceService: ObservableObject {
|
|
||||||
static let shared = GeohashPresenceService()
|
|
||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
|
||||||
private var heartbeatTimer: Timer?
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
// Loop interval range in seconds
|
|
||||||
private let loopMinInterval: TimeInterval = 40.0
|
|
||||||
private let loopMaxInterval: TimeInterval = 80.0
|
|
||||||
|
|
||||||
// Per-broadcast decorrelation delay range in seconds
|
|
||||||
private let burstMinDelay: TimeInterval = 2.0
|
|
||||||
private let burstMaxDelay: TimeInterval = 5.0
|
|
||||||
|
|
||||||
// Privacy: Only broadcast to these levels
|
|
||||||
private let allowedPrecisions: Set<Int> = [
|
|
||||||
GeohashChannelLevel.region.precision, // 2
|
|
||||||
GeohashChannelLevel.province.precision, // 4
|
|
||||||
GeohashChannelLevel.city.precision // 5
|
|
||||||
]
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
setupObservers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
|
||||||
func start() {
|
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
// Monitor location channel changes
|
|
||||||
LocationStateManager.shared.$availableChannels
|
|
||||||
.dropFirst()
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleLocationChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
|
||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleConnectivityChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleLocationChange() {
|
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
|
||||||
// to announce presence in the new zone, then reset the loop.
|
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleConnectivityChange() {
|
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
|
||||||
// If we were waiting for network, do it now
|
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleNextHeartbeat() {
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performHeartbeat() {
|
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
|
||||||
defer { scheduleNextHeartbeat() }
|
|
||||||
|
|
||||||
// 1. Check preconditions
|
|
||||||
guard TorManager.shared.isReady else {
|
|
||||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
|
||||||
if !TorManager.shared.isForeground() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get channels
|
|
||||||
let channels = LocationStateManager.shared.availableChannels
|
|
||||||
guard !channels.isEmpty else { return }
|
|
||||||
|
|
||||||
// 3. Filter and broadcast
|
|
||||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
|
||||||
for channel in channels {
|
|
||||||
// Check privacy restriction
|
|
||||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
|
||||||
Task { @MainActor in
|
|
||||||
// Random delay for decorrelation
|
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
|
|
||||||
self.broadcastPresence(for: channel.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func broadcastPresence(for geohash: String) {
|
|
||||||
do {
|
|
||||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: geohash,
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send via RelayManager
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,47 +10,6 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
// MARK: - Keychain Error Types
|
|
||||||
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
|
||||||
|
|
||||||
/// Result of a keychain read operation with proper error classification
|
|
||||||
enum KeychainReadResult {
|
|
||||||
case success(Data)
|
|
||||||
case itemNotFound // Expected: key doesn't exist yet
|
|
||||||
case accessDenied // Critical: app lacks keychain access
|
|
||||||
case deviceLocked // Recoverable: device is locked
|
|
||||||
case authenticationFailed // Recoverable: biometric/passcode failed
|
|
||||||
case otherError(OSStatus) // Unexpected error
|
|
||||||
|
|
||||||
var isRecoverableError: Bool {
|
|
||||||
switch self {
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a keychain save operation with proper error classification
|
|
||||||
enum KeychainSaveResult {
|
|
||||||
case success
|
|
||||||
case duplicateItem // Can retry with update
|
|
||||||
case accessDenied // Critical: app lacks keychain access
|
|
||||||
case deviceLocked // Recoverable: device is locked
|
|
||||||
case storageFull // Critical: no space available
|
|
||||||
case otherError(OSStatus)
|
|
||||||
|
|
||||||
var isRecoverableError: Bool {
|
|
||||||
switch self {
|
|
||||||
case .duplicateItem, .deviceLocked:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol KeychainManagerProtocol {
|
protocol KeychainManagerProtocol {
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
func getIdentityKey(forKey key: String) -> Data?
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
@@ -61,20 +20,6 @@ protocol KeychainManagerProtocol {
|
|||||||
func secureClear(_ string: inout String)
|
func secureClear(_ string: inout String)
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool
|
func verifyIdentityKeyExists() -> Bool
|
||||||
|
|
||||||
// BCH-01-009: Methods with proper error classification
|
|
||||||
/// Get identity key with detailed result for error handling
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
|
|
||||||
/// Save identity key with detailed result for error handling
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
|
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
|
||||||
/// Save data with a custom service name
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
|
||||||
/// Load data from a custom service
|
|
||||||
func load(key: String, service: String) -> Data?
|
|
||||||
/// Delete data from a custom service
|
|
||||||
func delete(key: String, service: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
@@ -102,180 +47,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Methods with Proper Error Classification
|
|
||||||
|
|
||||||
/// Get identity key with detailed result for proper error handling
|
|
||||||
/// Distinguishes between missing keys (expected) and critical failures
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return retrieveDataWithResult(forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save identity key with detailed result and retry logic for transient errors
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return saveDataWithResult(keyData, forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to save data with detailed result and retry for transient errors
|
|
||||||
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
|
|
||||||
// Delete any existing item first to ensure clean state
|
|
||||||
_ = delete(forKey: key)
|
|
||||||
|
|
||||||
// Build base query
|
|
||||||
var base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
|
||||||
]
|
|
||||||
#if os(macOS)
|
|
||||||
base[kSecAttrSynchronizable as String] = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func attempt(addAccessGroup: Bool) -> OSStatus {
|
|
||||||
var query = base
|
|
||||||
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(addAccessGroup: true)
|
|
||||||
if status == -34018 { // Missing entitlement, retry without access group
|
|
||||||
status = attempt(addAccessGroup: false)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
let status = attempt(addAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let result = classifySaveStatus(status)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .duplicateItem:
|
|
||||||
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
|
|
||||||
case .storageFull:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain storage full for key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain save failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry transient errors with exponential backoff
|
|
||||||
if result.isRecoverableError && retryCount > 0 {
|
|
||||||
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
|
|
||||||
usleep(delayMs * 1000)
|
|
||||||
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
|
|
||||||
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to retrieve data with detailed result
|
|
||||||
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
func attempt(withAccessGroup: Bool) -> OSStatus {
|
|
||||||
var q = base
|
|
||||||
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemCopyMatching(q as CFDictionary, &result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(withAccessGroup: true)
|
|
||||||
if status == -34018 { status = attempt(withAccessGroup: false) }
|
|
||||||
#else
|
|
||||||
let status = attempt(withAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let readResult = classifyReadStatus(status, data: result as? Data)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch readResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case - no logging needed for missing keys
|
|
||||||
break
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
|
|
||||||
case .authenticationFailed:
|
|
||||||
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain read failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return readResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain read status into meaningful categories
|
|
||||||
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
if let data = data {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .otherError(status)
|
|
||||||
case errSecItemNotFound:
|
|
||||||
return .itemNotFound
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
// Device is locked or in a state that doesn't allow keychain access
|
|
||||||
return .deviceLocked
|
|
||||||
case errSecAuthFailed:
|
|
||||||
return .authenticationFailed
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain save status into meaningful categories
|
|
||||||
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
return .success
|
|
||||||
case errSecDuplicateItem:
|
|
||||||
return .duplicateItem
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
return .deviceLocked
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
case errSecDiskFull:
|
|
||||||
return .storageFull
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Operations
|
// MARK: - Generic Operations
|
||||||
|
|
||||||
private func save(_ value: String, forKey key: String) -> Bool {
|
private func save(_ value: String, forKey key: String) -> Bool {
|
||||||
@@ -543,49 +314,4 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
let key = "identity_noiseStaticKey"
|
let key = "identity_noiseStaticKey"
|
||||||
return retrieveData(forKey: key) != nil
|
return retrieveData(forKey: key) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
|
||||||
|
|
||||||
/// Save data with a custom service name
|
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: customService,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
|
||||||
if let accessible = accessible {
|
|
||||||
query[kSecAttrAccessible as String] = accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load data from a custom service
|
|
||||||
func load(key: String, service customService: String) -> Data? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: customService,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete data from a custom service
|
|
||||||
func delete(key: String, service customService: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: customService,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||||
|
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||||
|
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||||
|
static let shared = LocationChannelManager()
|
||||||
|
|
||||||
|
enum PermissionState: Equatable {
|
||||||
|
case notDetermined
|
||||||
|
case denied
|
||||||
|
case restricted
|
||||||
|
case authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private let cl = CLLocationManager()
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var lastLocation: CLLocation?
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private let userDefaultsKey = "locationChannel.selected"
|
||||||
|
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||||
|
private var isGeocoding: Bool = false
|
||||||
|
|
||||||
|
// Published state for UI bindings
|
||||||
|
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||||
|
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||||
|
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||||
|
// True when the current location channel was selected via manual teleport
|
||||||
|
@Published var teleported: Bool = false
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||||
|
|
||||||
|
// Persisted set of geohashes that were selected via teleport
|
||||||
|
private var teleportedSet: Set<String> = []
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
cl.delegate = self
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||||
|
// Load selection
|
||||||
|
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||||
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||||
|
selectedChannel = channel
|
||||||
|
}
|
||||||
|
// Load persisted teleported set
|
||||||
|
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
teleportedSet = Set(arr)
|
||||||
|
}
|
||||||
|
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||||
|
// This avoids showing teleported for in-region channels during cold start.
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
break // will compute from location
|
||||||
|
case .notDetermined, .restricted, .denied:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
func enableLocationChannels() {
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case .notDetermined:
|
||||||
|
cl.requestWhenInUseAuthorization()
|
||||||
|
case .restricted:
|
||||||
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
|
case .denied:
|
||||||
|
Task { @MainActor in self.permissionState = .denied }
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
Task { @MainActor in self.permissionState = .authorized }
|
||||||
|
requestOneShotLocation()
|
||||||
|
@unknown default:
|
||||||
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannels() {
|
||||||
|
if permissionState == .authorized {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||||
|
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||||
|
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||||
|
guard permissionState == .authorized else { return }
|
||||||
|
// Stop any previous polling timer
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
// Tighten accuracy and distance filter for live view
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||||
|
// Start continuous updates
|
||||||
|
cl.startUpdatingLocation()
|
||||||
|
// Request an immediate fix to populate UI without waiting for movement
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop continuous refreshes when selector UI is dismissed.
|
||||||
|
func endLiveRefresh() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
cl.stopUpdatingLocation()
|
||||||
|
// Restore more relaxed defaults for background/idle state
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ channel: ChannelID) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.selectedChannel = channel
|
||||||
|
if let data = try? JSONEncoder().encode(channel) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||||
|
}
|
||||||
|
// Update teleported flag based on persisted state for immediate UI behavior
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||||
|
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport for this geohash to keep future selections clean
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||||
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||||
|
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||||
|
}
|
||||||
|
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||||
|
Task { @MainActor in self.teleported = flag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreLocation
|
||||||
|
private func requestOneShotLocation() {
|
||||||
|
cl.requestLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS < 14
|
||||||
|
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
if case .authorized = permissionState {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS 14+ / macOS 11+
|
||||||
|
@available(iOS 14.0, macOS 11.0, *)
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
updatePermissionState(from: manager.authorizationStatus)
|
||||||
|
if case .authorized = permissionState {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard let loc = locations.last else { return }
|
||||||
|
lastLocation = loc
|
||||||
|
computeChannels(from: loc.coordinate)
|
||||||
|
reverseGeocodeIfNeeded(location: loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||||
|
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||||
|
let newState: PermissionState
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: newState = .notDetermined
|
||||||
|
case .restricted: newState = .restricted
|
||||||
|
case .denied: newState = .denied
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||||
|
@unknown default: newState = .restricted
|
||||||
|
}
|
||||||
|
Task { @MainActor in self.permissionState = newState }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||||
|
let levels = GeohashChannelLevel.allCases
|
||||||
|
var result: [GeohashChannel] = []
|
||||||
|
for level in levels {
|
||||||
|
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||||
|
result.append(GeohashChannel(level: level, geohash: gh))
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
self.availableChannels = result
|
||||||
|
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||||
|
switch self.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||||
|
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport flag if present
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.teleported = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||||
|
// Always cancel previous to keep latest fresh while user moves
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
isGeocoding = true
|
||||||
|
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isGeocoding = false
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let names = self.namesByLevel(from: pm)
|
||||||
|
Task { @MainActor in self.locationNames = names }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||||
|
var dict: [GeohashChannelLevel: String] = [:]
|
||||||
|
// Region (country)
|
||||||
|
if let country = pm.country, !country.isEmpty {
|
||||||
|
dict[.region] = country
|
||||||
|
}
|
||||||
|
// Province (state/province or county)
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.province] = admin
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.province] = subAdmin
|
||||||
|
}
|
||||||
|
// City (locality)
|
||||||
|
if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.city] = locality
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.city] = subAdmin
|
||||||
|
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.city] = admin
|
||||||
|
}
|
||||||
|
// Neighborhood
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.neighborhood] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.neighborhood] = locality
|
||||||
|
}
|
||||||
|
// Block: reuse neighborhood/locality granularity
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.block] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.block] = locality
|
||||||
|
}
|
||||||
|
// Building: prefer place name/street/venue when available
|
||||||
|
if let name = pm.name, !name.isEmpty {
|
||||||
|
dict[.building] = name
|
||||||
|
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||||
|
dict[.building] = thoroughfare
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
import CoreLocation
|
|
||||||
|
|
||||||
/// Unified manager for location-based channel state including:
|
|
||||||
/// - CoreLocation permissions and one-shot location retrieval
|
|
||||||
/// - Geohash channel computation from coordinates
|
|
||||||
/// - Channel selection and teleport state
|
|
||||||
/// - Bookmark persistence and friendly name resolution
|
|
||||||
///
|
|
||||||
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
|
|
||||||
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
|
||||||
static let shared = LocationStateManager()
|
|
||||||
|
|
||||||
// MARK: - Permission State
|
|
||||||
|
|
||||||
enum PermissionState: Equatable {
|
|
||||||
case notDetermined
|
|
||||||
case denied
|
|
||||||
case restricted
|
|
||||||
case authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
|
||||||
|
|
||||||
private let cl = CLLocationManager()
|
|
||||||
private let geocoder = CLGeocoder()
|
|
||||||
private var lastLocation: CLLocation?
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var isGeocoding: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Persistence Keys
|
|
||||||
|
|
||||||
private let selectedChannelKey = "locationChannel.selected"
|
|
||||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
|
||||||
private let bookmarksKey = "locationChannel.bookmarks"
|
|
||||||
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
|
|
||||||
|
|
||||||
// MARK: - Published State (Channel)
|
|
||||||
|
|
||||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
|
||||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
|
||||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
|
||||||
@Published var teleported: Bool = false
|
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
|
||||||
|
|
||||||
// MARK: - Published State (Bookmarks)
|
|
||||||
|
|
||||||
@Published private(set) var bookmarks: [String] = []
|
|
||||||
@Published private(set) var bookmarkNames: [String: String] = [:]
|
|
||||||
|
|
||||||
// MARK: - Private State
|
|
||||||
|
|
||||||
private var teleportedSet: Set<String> = []
|
|
||||||
private var bookmarkMembership: Set<String> = []
|
|
||||||
private var resolvingNames: Set<String> = []
|
|
||||||
private let storage: UserDefaults
|
|
||||||
|
|
||||||
/// Returns true if running in test environment
|
|
||||||
private static var isRunningTests: Bool {
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
self.storage = .standard
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
// Skip CoreLocation setup in test environments
|
|
||||||
guard !Self.isRunningTests else {
|
|
||||||
loadPersistedState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cl.delegate = self
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
|
|
||||||
loadPersistedState()
|
|
||||||
initializePermissionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal initializer for testing with custom storage
|
|
||||||
init(storage: UserDefaults) {
|
|
||||||
self.storage = storage
|
|
||||||
super.init()
|
|
||||||
loadPersistedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPersistedState() {
|
|
||||||
// Load selected channel
|
|
||||||
if let data = storage.data(forKey: selectedChannelKey),
|
|
||||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
|
||||||
selectedChannel = channel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load teleported set
|
|
||||||
if let data = storage.data(forKey: teleportedStoreKey),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
teleportedSet = Set(arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmarks
|
|
||||||
if let data = storage.data(forKey: bookmarksKey),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
var seen = Set<String>()
|
|
||||||
var list: [String] = []
|
|
||||||
for raw in arr {
|
|
||||||
let gh = Self.normalizeGeohash(raw)
|
|
||||||
guard !gh.isEmpty, !seen.contains(gh) else { continue }
|
|
||||||
seen.insert(gh)
|
|
||||||
list.append(gh)
|
|
||||||
}
|
|
||||||
bookmarks = list
|
|
||||||
bookmarkMembership = seen
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmark names
|
|
||||||
if let data = storage.data(forKey: bookmarkNamesKey),
|
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
|
|
||||||
bookmarkNames = dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initializePermissionState() {
|
|
||||||
let status: CLAuthorizationStatus
|
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
updatePermissionState(from: status)
|
|
||||||
|
|
||||||
// Fall back to persisted teleport state if no location authorization
|
|
||||||
switch status {
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
||||||
break
|
|
||||||
case .notDetermined, .restricted, .denied:
|
|
||||||
fallthrough
|
|
||||||
@unknown default:
|
|
||||||
if case .location(let ch) = selectedChannel {
|
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Permissions & Location)
|
|
||||||
|
|
||||||
func enableLocationChannels() {
|
|
||||||
let status: CLAuthorizationStatus
|
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case .notDetermined:
|
|
||||||
cl.requestWhenInUseAuthorization()
|
|
||||||
case .restricted:
|
|
||||||
Task { @MainActor in self.permissionState = .restricted }
|
|
||||||
case .denied:
|
|
||||||
Task { @MainActor in self.permissionState = .denied }
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
||||||
Task { @MainActor in self.permissionState = .authorized }
|
|
||||||
requestOneShotLocation()
|
|
||||||
@unknown default:
|
|
||||||
Task { @MainActor in self.permissionState = .restricted }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshChannels() {
|
|
||||||
if permissionState == .authorized {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
|
||||||
guard permissionState == .authorized else { return }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
|
||||||
cl.startUpdatingLocation()
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func endLiveRefresh() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
cl.stopUpdatingLocation()
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Channel Selection)
|
|
||||||
|
|
||||||
func select(_ channel: ChannelID) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.selectedChannel = channel
|
|
||||||
if let data = try? JSONEncoder().encode(channel) {
|
|
||||||
self.storage.set(data, forKey: self.selectedChannelKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
self.teleported = false
|
|
||||||
case .location(let ch):
|
|
||||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
if inRegional {
|
|
||||||
self.teleported = false
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
self.persistTeleportedSet()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
|
||||||
if flag {
|
|
||||||
teleportedSet.insert(geohash)
|
|
||||||
} else {
|
|
||||||
teleportedSet.remove(geohash)
|
|
||||||
}
|
|
||||||
persistTeleportedSet()
|
|
||||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
|
||||||
Task { @MainActor in self.teleported = flag }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Bookmarks)
|
|
||||||
|
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
|
||||||
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
if bookmarkMembership.contains(gh) {
|
|
||||||
removeBookmark(gh)
|
|
||||||
} else {
|
|
||||||
addBookmark(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
|
|
||||||
bookmarks.insert(gh, at: 0)
|
|
||||||
bookmarkMembership.insert(gh)
|
|
||||||
persistBookmarks()
|
|
||||||
resolveBookmarkNameIfNeeded(for: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard bookmarkMembership.contains(gh) else { return }
|
|
||||||
if let idx = bookmarks.firstIndex(of: gh) {
|
|
||||||
bookmarks.remove(at: idx)
|
|
||||||
}
|
|
||||||
bookmarkMembership.remove(gh)
|
|
||||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
|
||||||
persistBookmarkNames()
|
|
||||||
}
|
|
||||||
persistBookmarks()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
|
||||||
|
|
||||||
private func requestOneShotLocation() {
|
|
||||||
cl.requestLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
||||||
updatePermissionState(from: status)
|
|
||||||
if case .authorized = permissionState {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 14.0, macOS 11.0, *)
|
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
||||||
updatePermissionState(from: manager.authorizationStatus)
|
|
||||||
if case .authorized = permissionState {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
||||||
guard let loc = locations.last else { return }
|
|
||||||
lastLocation = loc
|
|
||||||
computeChannels(from: loc.coordinate)
|
|
||||||
reverseGeocodeLocation(loc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
||||||
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Permission)
|
|
||||||
|
|
||||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
|
||||||
let newState: PermissionState
|
|
||||||
switch status {
|
|
||||||
case .notDetermined: newState = .notDetermined
|
|
||||||
case .restricted: newState = .restricted
|
|
||||||
case .denied: newState = .denied
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
|
||||||
@unknown default: newState = .restricted
|
|
||||||
}
|
|
||||||
Task { @MainActor in self.permissionState = newState }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Channel Computation)
|
|
||||||
|
|
||||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
|
||||||
let levels = GeohashChannelLevel.allCases
|
|
||||||
var result: [GeohashChannel] = []
|
|
||||||
for level in levels {
|
|
||||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
|
||||||
result.append(GeohashChannel(level: level, geohash: gh))
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
self.availableChannels = result
|
|
||||||
switch self.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
self.teleported = false
|
|
||||||
case .location(let ch):
|
|
||||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
|
||||||
if inRegional {
|
|
||||||
self.teleported = false
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
self.persistTeleportedSet()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Geocoding)
|
|
||||||
|
|
||||||
private func reverseGeocodeLocation(_ location: CLLocation) {
|
|
||||||
geocoder.cancelGeocode()
|
|
||||||
isGeocoding = true
|
|
||||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.isGeocoding = false
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
let names = self.locationNamesByLevel(from: pm)
|
|
||||||
Task { @MainActor in self.locationNames = names }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
|
||||||
var dict: [GeohashChannelLevel: String] = [:]
|
|
||||||
if let country = pm.country, !country.isEmpty {
|
|
||||||
dict[.region] = country
|
|
||||||
}
|
|
||||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
||||||
dict[.province] = admin
|
|
||||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
||||||
dict[.province] = subAdmin
|
|
||||||
}
|
|
||||||
if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.city] = locality
|
|
||||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
||||||
dict[.city] = subAdmin
|
|
||||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
||||||
dict[.city] = admin
|
|
||||||
}
|
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
||||||
dict[.neighborhood] = subLocality
|
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.neighborhood] = locality
|
|
||||||
}
|
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
||||||
dict[.block] = subLocality
|
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.block] = locality
|
|
||||||
}
|
|
||||||
if let name = pm.name, !name.isEmpty {
|
|
||||||
dict[.building] = name
|
|
||||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
|
||||||
dict[.building] = thoroughfare
|
|
||||||
}
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
|
|
||||||
resolvingNames.insert(gh)
|
|
||||||
|
|
||||||
if gh.count <= 2 {
|
|
||||||
let b = Geohash.decodeBounds(gh)
|
|
||||||
let pts: [CLLocation] = [
|
|
||||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2),
|
|
||||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
|
||||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
|
||||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
|
||||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
|
||||||
]
|
|
||||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
|
||||||
} else {
|
|
||||||
let center = Geohash.decodeCenter(gh)
|
|
||||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
|
||||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
defer { self.resolvingNames.remove(gh) }
|
|
||||||
if let pm = placemarks?.first,
|
|
||||||
let name = Self.nameForGeohashLength(gh.count, from: pm),
|
|
||||||
!name.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = name
|
|
||||||
self.persistBookmarkNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
|
||||||
var uniqueAdmins: [String] = []
|
|
||||||
var seenAdmins = Set<String>()
|
|
||||||
var idx = 0
|
|
||||||
|
|
||||||
func step() {
|
|
||||||
if idx >= points.count {
|
|
||||||
let finalName: String? = {
|
|
||||||
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
|
|
||||||
return uniqueAdmins.first
|
|
||||||
}()
|
|
||||||
if let finalName = finalName, !finalName.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = finalName
|
|
||||||
self.persistBookmarkNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.resolvingNames.remove(gh)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let loc = points[idx]
|
|
||||||
idx += 1
|
|
||||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
|
||||||
guard self != nil else { return }
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {
|
|
||||||
seenAdmins.insert(admin)
|
|
||||||
uniqueAdmins.append(admin)
|
|
||||||
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
|
|
||||||
seenAdmins.insert(country)
|
|
||||||
uniqueAdmins.append(country)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
|
||||||
switch len {
|
|
||||||
case 0...2:
|
|
||||||
return pm.administrativeArea ?? pm.country
|
|
||||||
case 3...4:
|
|
||||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
|
||||||
case 5:
|
|
||||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
|
||||||
case 6...7:
|
|
||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
|
||||||
default:
|
|
||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Persistence)
|
|
||||||
|
|
||||||
private func persistTeleportedSet() {
|
|
||||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
|
||||||
storage.set(data, forKey: teleportedStoreKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistBookmarks() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
|
||||||
storage.set(data, forKey: bookmarksKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistBookmarkNames() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
|
||||||
storage.set(data, forKey: bookmarkNamesKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeGeohash(_ s: String) -> String {
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
return s
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: "#", with: "")
|
|
||||||
.filter { allowed.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Backward Compatibility Typealiases
|
|
||||||
|
|
||||||
typealias LocationChannelManager = LocationStateManager
|
|
||||||
typealias GeohashBookmarksStore = LocationStateManager
|
|
||||||
|
|
||||||
// MARK: - Backward Compatibility Extensions
|
|
||||||
|
|
||||||
extension LocationStateManager {
|
|
||||||
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
|
|
||||||
func toggle(_ geohash: String) {
|
|
||||||
toggleBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
|
|
||||||
func add(_ geohash: String) {
|
|
||||||
addBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
|
|
||||||
func remove(_ geohash: String) {
|
|
||||||
removeBookmark(geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks observed mesh topology and computes hop-by-hop routes.
|
|
||||||
final class MeshTopologyTracker {
|
|
||||||
private typealias RoutingID = Data
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
|
||||||
private let hopSize = 8
|
|
||||||
// Directed claims: Key claims to see Value (neighbors)
|
|
||||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
|
||||||
// Last time we received an update from a node
|
|
||||||
private var lastSeen: [RoutingID: Date] = [:]
|
|
||||||
|
|
||||||
// Maximum age for topology claims to be considered fresh for routing
|
|
||||||
// Routes computed using stale topology can fail when the network has changed
|
|
||||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.claims.removeAll()
|
|
||||||
self.lastSeen.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the topology with a node's self-reported neighbor list
|
|
||||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
|
||||||
guard let source = sanitize(sourceData) else { return }
|
|
||||||
// Sanitize neighbors and exclude self-loops
|
|
||||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.claims[source] = validNeighbors
|
|
||||||
self.lastSeen[source] = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePeer(_ data: Data?) {
|
|
||||||
guard let peer = sanitize(data) else { return }
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.claims.removeValue(forKey: peer)
|
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
|
||||||
func prune(olderThan age: TimeInterval) {
|
|
||||||
let deadline = Date().addingTimeInterval(-age)
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
|
||||||
for (peer, _) in stale {
|
|
||||||
self.claims.removeValue(forKey: peer)
|
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
|
||||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
|
||||||
if source == target { return [] } // Direct connection, no intermediate hops
|
|
||||||
|
|
||||||
return queue.sync {
|
|
||||||
let now = Date()
|
|
||||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
|
||||||
|
|
||||||
// BFS
|
|
||||||
var visited: Set<RoutingID> = [source]
|
|
||||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
|
||||||
var queuePaths: [[RoutingID]] = [[source]]
|
|
||||||
|
|
||||||
while !queuePaths.isEmpty {
|
|
||||||
let path = queuePaths.removeFirst()
|
|
||||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
|
||||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
|
||||||
if path.count > maxHops + 1 { continue }
|
|
||||||
|
|
||||||
guard let last = path.last else { continue }
|
|
||||||
|
|
||||||
// Get neighbors that 'last' claims to see
|
|
||||||
guard let neighbors = claims[last] else { continue }
|
|
||||||
|
|
||||||
// Check if 'last' node's topology info is fresh
|
|
||||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
for neighbor in neighbors {
|
|
||||||
if visited.contains(neighbor) { continue }
|
|
||||||
|
|
||||||
// CONFIRMED EDGE CHECK:
|
|
||||||
// 'last' claims 'neighbor' (checked above)
|
|
||||||
// Does 'neighbor' claim 'last'?
|
|
||||||
guard let neighborClaims = claims[neighbor],
|
|
||||||
neighborClaims.contains(last) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if 'neighbor' node's topology info is fresh
|
|
||||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip edges to stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextPath = path
|
|
||||||
nextPath.append(neighbor)
|
|
||||||
|
|
||||||
if neighbor == target {
|
|
||||||
// Return only intermediate hops
|
|
||||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
|
||||||
return Array(nextPath.dropFirst().dropLast())
|
|
||||||
}
|
|
||||||
|
|
||||||
visited.insert(neighbor)
|
|
||||||
queuePaths.append(nextPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func sanitize(_ data: Data?) -> Data? {
|
|
||||||
guard var value = data, !value.isEmpty else { return nil }
|
|
||||||
if value.count > hopSize {
|
|
||||||
value = Data(value.prefix(hopSize))
|
|
||||||
} else if value.count < hopSize {
|
|
||||||
value.append(Data(repeating: 0, count: hopSize - value.count))
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageDeduplicationService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles message deduplication using LRU caches.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - LRU Deduplication Cache
|
|
||||||
|
|
||||||
/// Generic LRU (Least Recently Used) cache for deduplication.
|
|
||||||
/// Uses an efficient O(1) lookup with periodic compaction.
|
|
||||||
/// Thread-safe via @MainActor - all callers are already on main actor.
|
|
||||||
@MainActor
|
|
||||||
final class LRUDeduplicationCache<Value> {
|
|
||||||
private var map: [String: Value] = [:]
|
|
||||||
private var order: [String] = []
|
|
||||||
private var head: Int = 0
|
|
||||||
private let capacity: Int
|
|
||||||
|
|
||||||
/// Creates a new LRU cache with the specified capacity.
|
|
||||||
/// - Parameter capacity: Maximum number of entries before eviction
|
|
||||||
init(capacity: Int) {
|
|
||||||
precondition(capacity > 0, "LRU cache capacity must be positive")
|
|
||||||
self.capacity = capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of active entries in the cache
|
|
||||||
var count: Int {
|
|
||||||
order.count - head
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key exists in the cache
|
|
||||||
func contains(_ key: String) -> Bool {
|
|
||||||
map[key] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the value for a key, or nil if not present
|
|
||||||
func value(for key: String) -> Value? {
|
|
||||||
map[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a key-value pair, updating if exists or inserting if new
|
|
||||||
func record(_ key: String, value: Value) {
|
|
||||||
if map[key] == nil {
|
|
||||||
order.append(key)
|
|
||||||
}
|
|
||||||
map[key] = value
|
|
||||||
trimIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes a specific key from the cache
|
|
||||||
func remove(_ key: String) {
|
|
||||||
map.removeValue(forKey: key)
|
|
||||||
// Note: key remains in order array but will be skipped during eviction
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears all entries from the cache
|
|
||||||
func clear() {
|
|
||||||
map.removeAll()
|
|
||||||
order.removeAll()
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
|
||||||
|
|
||||||
private func trimIfNeeded() {
|
|
||||||
let activeCount = order.count - head
|
|
||||||
guard activeCount > capacity else { return }
|
|
||||||
|
|
||||||
let overflow = activeCount - capacity
|
|
||||||
for _ in 0..<overflow {
|
|
||||||
guard let victim = popOldest() else { break }
|
|
||||||
map.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func popOldest() -> String? {
|
|
||||||
// Skip keys that were already removed from map
|
|
||||||
while head < order.count {
|
|
||||||
let key = order[head]
|
|
||||||
head += 1
|
|
||||||
|
|
||||||
// Periodically compact the backing storage
|
|
||||||
if head >= 32 && head * 2 >= order.count {
|
|
||||||
order.removeFirst(head)
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only return if key is still in map
|
|
||||||
if map[key] != nil {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Normalizer
|
|
||||||
|
|
||||||
/// Normalizes message content for near-duplicate detection.
|
|
||||||
enum ContentNormalizer {
|
|
||||||
|
|
||||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
|
||||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(
|
|
||||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Normalizes content for deduplication comparison.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - content: The raw message content
|
|
||||||
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
|
|
||||||
/// - Returns: A hash-based key for comparison
|
|
||||||
static func normalizedKey(
|
|
||||||
_ content: String,
|
|
||||||
prefixLength: Int = TransportConfig.contentKeyPrefixLength
|
|
||||||
) -> String {
|
|
||||||
// Lowercase for case-insensitive comparison
|
|
||||||
let lowered = content.lowercased()
|
|
||||||
let ns = lowered as NSString
|
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
|
||||||
|
|
||||||
// Simplify URLs by stripping query/fragment
|
|
||||||
var simplified = ""
|
|
||||||
var last = 0
|
|
||||||
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
|
|
||||||
if match.range.location > last {
|
|
||||||
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
|
||||||
}
|
|
||||||
let url = ns.substring(with: match.range)
|
|
||||||
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
|
|
||||||
simplified += String(url[..<queryIndex])
|
|
||||||
} else {
|
|
||||||
simplified += url
|
|
||||||
}
|
|
||||||
last = match.range.location + match.range.length
|
|
||||||
}
|
|
||||||
if last < ns.length {
|
|
||||||
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim and collapse whitespace
|
|
||||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
||||||
|
|
||||||
// Take prefix and hash
|
|
||||||
let prefix = String(collapsed.prefix(prefixLength))
|
|
||||||
let hash = prefix.djb2()
|
|
||||||
return String(format: "h:%016llx", hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Deduplication Service
|
|
||||||
|
|
||||||
/// Service that manages message deduplication using LRU caches.
|
|
||||||
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
|
|
||||||
/// Thread-safe via @MainActor - all callers are already on main actor.
|
|
||||||
@MainActor
|
|
||||||
final class MessageDeduplicationService {
|
|
||||||
|
|
||||||
/// Cache for content-based near-duplicate detection
|
|
||||||
private let contentCache: LRUDeduplicationCache<Date>
|
|
||||||
|
|
||||||
/// Cache for Nostr event ID deduplication
|
|
||||||
private let nostrEventCache: LRUDeduplicationCache<Bool>
|
|
||||||
|
|
||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
|
||||||
|
|
||||||
/// Creates a new deduplication service with specified capacities.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - contentCapacity: Max entries for content cache
|
|
||||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
|
||||||
init(
|
|
||||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
|
||||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
|
||||||
) {
|
|
||||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
|
||||||
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
|
||||||
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Deduplication
|
|
||||||
|
|
||||||
/// Records content with its timestamp for near-duplicate detection.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - content: The message content
|
|
||||||
/// - timestamp: When the content was received
|
|
||||||
func recordContent(_ content: String, timestamp: Date) {
|
|
||||||
let key = ContentNormalizer.normalizedKey(content)
|
|
||||||
contentCache.record(key, value: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a pre-normalized content key with its timestamp.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - key: The normalized content key
|
|
||||||
/// - timestamp: When the content was received
|
|
||||||
func recordContentKey(_ key: String, timestamp: Date) {
|
|
||||||
contentCache.record(key, value: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the timestamp for previously seen content.
|
|
||||||
/// - Parameter content: The message content
|
|
||||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
|
||||||
func contentTimestamp(for content: String) -> Date? {
|
|
||||||
let key = ContentNormalizer.normalizedKey(content)
|
|
||||||
return contentCache.value(for: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the timestamp for a pre-normalized content key.
|
|
||||||
/// - Parameter key: The normalized content key
|
|
||||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
|
||||||
func contentTimestamp(forKey key: String) -> Date? {
|
|
||||||
contentCache.value(for: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalizes content to a deduplication key.
|
|
||||||
/// - Parameter content: The raw content
|
|
||||||
/// - Returns: A normalized hash key
|
|
||||||
func normalizedContentKey(_ content: String) -> String {
|
|
||||||
ContentNormalizer.normalizedKey(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Event Deduplication
|
|
||||||
|
|
||||||
/// Checks if a Nostr event has already been processed.
|
|
||||||
/// - Parameter eventId: The event ID
|
|
||||||
/// - Returns: true if already processed
|
|
||||||
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
|
|
||||||
nostrEventCache.contains(eventId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a Nostr event as processed.
|
|
||||||
/// - Parameter eventId: The event ID
|
|
||||||
func recordNostrEvent(_ eventId: String) {
|
|
||||||
nostrEventCache.record(eventId, value: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr ACK Deduplication
|
|
||||||
|
|
||||||
/// Checks if a Nostr ACK has already been processed.
|
|
||||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
|
||||||
/// - Returns: true if already processed
|
|
||||||
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
|
|
||||||
nostrAckCache.contains(ackKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a Nostr ACK as processed.
|
|
||||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
|
||||||
func recordNostrAck(_ ackKey: String) {
|
|
||||||
nostrAckCache.record(ackKey, value: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an ACK key from components.
|
|
||||||
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
|
|
||||||
"\(messageId):\(ackType):\(senderPubkey)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear
|
|
||||||
|
|
||||||
/// Clears all caches
|
|
||||||
func clearAll() {
|
|
||||||
contentCache.clear()
|
|
||||||
nostrEventCache.clear()
|
|
||||||
nostrAckCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears only the Nostr caches (events and ACKs)
|
|
||||||
func clearNostrCaches() {
|
|
||||||
nostrEventCache.clear()
|
|
||||||
nostrAckCache.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageFormattingEngine.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Formatting Context Protocol
|
|
||||||
|
|
||||||
/// Protocol defining the context needed for message formatting.
|
|
||||||
/// Implemented by ChatViewModel to provide runtime state.
|
|
||||||
@MainActor
|
|
||||||
protocol MessageFormattingContext: AnyObject {
|
|
||||||
/// The user's current nickname
|
|
||||||
var nickname: String { get }
|
|
||||||
|
|
||||||
/// Determines if a message was sent by the current user
|
|
||||||
func isSelfMessage(_ message: BitchatMessage) -> Bool
|
|
||||||
|
|
||||||
/// Gets the color for a message's sender
|
|
||||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
|
|
||||||
|
|
||||||
/// Resolves a peer ID to a clickable URL
|
|
||||||
func peerURL(for peerID: PeerID) -> URL?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Formatting Engine
|
|
||||||
|
|
||||||
/// Handles rich text formatting for chat messages.
|
|
||||||
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
|
|
||||||
final class MessageFormattingEngine {
|
|
||||||
|
|
||||||
// MARK: - Precompiled Regexes
|
|
||||||
|
|
||||||
/// Precompiled regex patterns for message content parsing
|
|
||||||
enum Patterns {
|
|
||||||
static let hashtag: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let mention: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let cashu: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let bolt11: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let lnurl: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let lightningScheme: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let linkDetector: NSDataDetector? = {
|
|
||||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let quickCashuPresence: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Match Types
|
|
||||||
|
|
||||||
/// Types of matches found in message content
|
|
||||||
enum MatchType: String {
|
|
||||||
case hashtag
|
|
||||||
case mention
|
|
||||||
case url
|
|
||||||
case cashu
|
|
||||||
case lightning
|
|
||||||
case bolt11
|
|
||||||
case lnurl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A match found in message content
|
|
||||||
struct ContentMatch {
|
|
||||||
let range: NSRange
|
|
||||||
let type: MatchType
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Formats a message with rich text styling
|
|
||||||
@MainActor
|
|
||||||
static func formatMessage(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
context: MessageFormattingContext,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) -> AttributedString {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let isSelf = context.isSelfMessage(message)
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = AttributedString()
|
|
||||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
|
||||||
|
|
||||||
// Format system messages differently
|
|
||||||
if message.sender == "system" {
|
|
||||||
result = formatSystemMessage(message, isDark: isDark)
|
|
||||||
} else {
|
|
||||||
// Format sender header
|
|
||||||
result = formatSenderHeader(
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
context: context
|
|
||||||
)
|
|
||||||
|
|
||||||
// Format content
|
|
||||||
let contentResult = formatContent(
|
|
||||||
message.content,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
|
||||||
)
|
|
||||||
result.append(contentResult)
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
result.append(formatTimestamp(message.formattedTimestamp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats just the message header (sender portion)
|
|
||||||
@MainActor
|
|
||||||
static func formatHeader(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
context: MessageFormattingContext,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) -> AttributedString {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let isSelf = context.isSelfMessage(message)
|
|
||||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
|
||||||
|
|
||||||
if message.sender == "system" {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
return AttributedString(message.sender).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatSenderHeader(
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
context: context
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts mentions from message content
|
|
||||||
static func extractMentions(from content: String) -> [String] {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = Patterns.mention.matches(in: content, options: [], range: range)
|
|
||||||
|
|
||||||
return matches.compactMap { match -> String? in
|
|
||||||
guard match.numberOfRanges > 1 else { return nil }
|
|
||||||
let captureRange = match.range(at: 1)
|
|
||||||
guard let swiftRange = Range(captureRange, in: content) else { return nil }
|
|
||||||
return String(content[swiftRange])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if content contains a Cashu token
|
|
||||||
static func containsCashuToken(_ content: String) -> Bool {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let content = AttributedString("* \(message.content) *")
|
|
||||||
var contentStyle = AttributeContainer()
|
|
||||||
contentStyle.foregroundColor = Color.gray
|
|
||||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
|
||||||
result.append(content.mergingAttributes(contentStyle))
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
||||||
var timestampStyle = AttributeContainer()
|
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
|
||||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func formatSenderHeader(
|
|
||||||
message: BitchatMessage,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
context: MessageFormattingContext
|
|
||||||
) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let (baseName, suffix) = message.sender.splitSuffix()
|
|
||||||
var senderStyle = AttributeContainer()
|
|
||||||
senderStyle.foregroundColor = baseColor
|
|
||||||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
|
||||||
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
|
||||||
|
|
||||||
// Make sender clickable
|
|
||||||
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
|
|
||||||
senderStyle.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build: "<@baseName#suffix> "
|
|
||||||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
|
||||||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
|
||||||
|
|
||||||
if !suffix.isEmpty {
|
|
||||||
var suffixStyle = senderStyle
|
|
||||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
|
||||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatContent(
|
|
||||||
_ content: String,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
isMentioned: Bool
|
|
||||||
) -> AttributedString {
|
|
||||||
// For very long content without special tokens, use plain formatting
|
|
||||||
let containsCashu = containsCashuToken(content)
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all matches
|
|
||||||
let matches = findAllMatches(in: content)
|
|
||||||
|
|
||||||
// Build formatted content
|
|
||||||
var result = AttributedString()
|
|
||||||
var lastEnd = content.startIndex
|
|
||||||
|
|
||||||
for match in matches {
|
|
||||||
guard let swiftRange = Range(match.range, in: content) else { continue }
|
|
||||||
|
|
||||||
// Add text before match
|
|
||||||
if lastEnd < swiftRange.lowerBound {
|
|
||||||
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
|
||||||
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add styled match
|
|
||||||
let matchText = String(content[swiftRange])
|
|
||||||
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
|
|
||||||
|
|
||||||
lastEnd = swiftRange.upperBound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if lastEnd < content.endIndex {
|
|
||||||
let remainingText = String(content[lastEnd...])
|
|
||||||
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func findAllMatches(in content: String) -> [ContentMatch] {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let nsLen = nsContent.length
|
|
||||||
let fullRange = NSRange(location: 0, length: nsLen)
|
|
||||||
|
|
||||||
// Quick hints to avoid unnecessary regex work
|
|
||||||
let hasMentions = content.contains("@")
|
|
||||||
let hasHashtags = content.contains("#")
|
|
||||||
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
|
|
||||||
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
|
||||||
let hasCashu = content.lowercased().contains("cashu")
|
|
||||||
|
|
||||||
// Collect matches
|
|
||||||
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
|
|
||||||
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
|
|
||||||
// Build mention ranges for overlap checking
|
|
||||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
|
||||||
|
|
||||||
func overlapsMention(_ r: NSRange) -> Bool {
|
|
||||||
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
|
||||||
guard let swiftRange = Range(r, in: content) else { return false }
|
|
||||||
if swiftRange.lowerBound == content.startIndex { return true }
|
|
||||||
let prev = content.index(before: swiftRange.lowerBound)
|
|
||||||
return content[prev].isWhitespace || content[prev].isNewline
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachedToMention(_ r: NSRange) -> Bool {
|
|
||||||
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
|
|
||||||
var i = content.index(before: swiftRange.lowerBound)
|
|
||||||
while true {
|
|
||||||
let ch = content[i]
|
|
||||||
if ch.isWhitespace || ch.isNewline { break }
|
|
||||||
if ch == "@" { return true }
|
|
||||||
if i == content.startIndex { break }
|
|
||||||
i = content.index(before: i)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var allMatches: [ContentMatch] = []
|
|
||||||
|
|
||||||
// Add hashtags (excluding those attached to mentions)
|
|
||||||
for match in hashtagMatches {
|
|
||||||
let range = match.range(at: 0)
|
|
||||||
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
|
|
||||||
allMatches.append(ContentMatch(range: range, type: .hashtag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add mentions
|
|
||||||
for match in mentionMatches {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add URLs
|
|
||||||
for match in urlMatches where !overlapsMention(match.range) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range, type: .url))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Cashu tokens
|
|
||||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Lightning scheme URLs
|
|
||||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
|
|
||||||
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
|
||||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
|
||||||
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
|
|
||||||
}
|
|
||||||
|
|
||||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by position
|
|
||||||
return allMatches.sorted { $0.range.location < $1.range.location }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
return AttributedString(content).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
|
|
||||||
guard !text.isEmpty else { return AttributedString() }
|
|
||||||
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
|
|
||||||
if isMentioned {
|
|
||||||
style.font = style.font?.bold()
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(text).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .mention:
|
|
||||||
// Split optional '#abcd' suffix
|
|
||||||
let (baseName, suffix) = text.splitSuffix()
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
var mentionStyle = AttributeContainer()
|
|
||||||
mentionStyle.foregroundColor = .blue
|
|
||||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
|
||||||
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
|
|
||||||
|
|
||||||
if !suffix.isEmpty {
|
|
||||||
var suffixStyle = mentionStyle
|
|
||||||
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
||||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
case .hashtag:
|
|
||||||
style.foregroundColor = .purple
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
|
|
||||||
case .url:
|
|
||||||
style.foregroundColor = .blue
|
|
||||||
style.font = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
style.underlineStyle = .single
|
|
||||||
if let url = URL(string: text) {
|
|
||||||
style.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
case .cashu:
|
|
||||||
style.foregroundColor = .green
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
style.backgroundColor = Color.green.opacity(0.1)
|
|
||||||
|
|
||||||
case .lightning, .bolt11, .lnurl:
|
|
||||||
style.foregroundColor = .yellow
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
style.backgroundColor = Color.yellow.opacity(0.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(text).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
|
|
||||||
let text = AttributedString(" [\(timestamp)]")
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = Color.gray.opacity(0.5)
|
|
||||||
style.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
return text.mergingAttributes(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
/// Routes messages between BLE and Nostr transports
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let mesh: Transport
|
||||||
|
private let nostr: NostrTransport
|
||||||
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
// Outbox entry with timestamp for TTL-based eviction
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
private struct QueuedMessage {
|
self.mesh = mesh
|
||||||
let content: String
|
self.nostr = nostr
|
||||||
let nickname: String
|
self.nostr.senderPeerID = mesh.myPeerID
|
||||||
let messageID: String
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
|
||||||
private static let maxMessagesPerPeer = 100
|
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
|
||||||
|
|
||||||
init(transports: [Transport]) {
|
|
||||||
self.transports = transports
|
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -47,87 +37,89 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Selection
|
|
||||||
|
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
|
||||||
transports.first { $0.isPeerReachable(peerID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
|
||||||
transports.first { $0.isPeerConnected(peerID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Sending
|
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
if reachableMesh {
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
|
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later with timestamp for TTL tracking
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
outbox[peerID]?.append(message)
|
|
||||||
|
|
||||||
// Enforce per-peer size limit with FIFO eviction
|
|
||||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
|
||||||
let evicted = outbox[peerID]?.removeFirst()
|
|
||||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
if mesh.isPeerReachable(peerID) {
|
||||||
transport.sendReadReceipt(receipt, to: peerID)
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
} else if !transports.isEmpty {
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
} else {
|
||||||
|
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
} else {
|
||||||
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
if let transport = connectedTransport(for: peerID) {
|
// Route via mesh when connected; else use Nostr
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
if mesh.isPeerConnected(peerID) {
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
} else {
|
||||||
|
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
|
// Two forms are supported:
|
||||||
|
// - 64-hex Noise public key (32 bytes)
|
||||||
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
|
if let noiseKey = peerID.noiseKey {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if peerID.isShort {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
let now = Date()
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
var remaining: [QueuedMessage] = []
|
for (content, nickname, messageID) in queued {
|
||||||
|
if mesh.isPeerReachable(peerID) {
|
||||||
for message in queued {
|
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Skip expired messages (TTL exceeded)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
continue
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
}
|
|
||||||
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
|
||||||
} else {
|
} else {
|
||||||
remaining.append(message)
|
// Keep unsent items queued
|
||||||
|
remaining.append((content, nickname, messageID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Persist only items we could not send
|
||||||
if remaining.isEmpty {
|
if remaining.isEmpty {
|
||||||
outbox.removeValue(forKey: peerID)
|
outbox.removeValue(forKey: peerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -138,15 +130,4 @@ final class MessageRouter {
|
|||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periodically clean up expired messages from all outboxes
|
|
||||||
func cleanupExpiredMessages() {
|
|
||||||
let now = Date()
|
|
||||||
for peerID in Array(outbox.keys) {
|
|
||||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
|
||||||
if outbox[peerID]?.isEmpty == true {
|
|
||||||
outbox.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
|
|||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy support - setting this will add to the handlers array
|
// Legacy support - setting this will add to the handlers array
|
||||||
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||||
get { nil } // Always return nil for backward compatibility
|
get { nil } // Always return nil for backward compatibility
|
||||||
set {
|
set {
|
||||||
if let handler = newValue {
|
if let handler = newValue {
|
||||||
@@ -200,85 +200,46 @@ final class NoiseEncryptionService {
|
|||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// BCH-01-009: Load or create static identity key with proper error handling
|
// Load or create static identity key (ONLY from keychain)
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain with proper error classification
|
// Try to load from keychain
|
||||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
||||||
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
switch noiseKeyResult {
|
loadedKey = key
|
||||||
case .success(let identityData):
|
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||||
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
}
|
||||||
loadedKey = key
|
// If no identity exists, create new one
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
else {
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key (will be lost on restart)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
let keyData = loadedKey.rawRepresentation
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
// Save to keychain
|
||||||
// Recoverable error - use ephemeral key and warn
|
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
|
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now assign the final value
|
// Now assign the final value
|
||||||
self.staticIdentityKey = loadedKey
|
self.staticIdentityKey = loadedKey
|
||||||
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
||||||
|
|
||||||
// BCH-01-009: Load or create signing key pair with proper error handling
|
// Load or create signing key pair
|
||||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
|
// Try to load from keychain
|
||||||
|
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||||
switch signingKeyResult {
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
case .success(let signingData):
|
loadedSigningKey = key
|
||||||
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||||
loadedSigningKey = key
|
}
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
// If no signing key exists, create new one
|
||||||
} else {
|
else {
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||||
|
let keyData = loadedSigningKey.rawRepresentation
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
// Save to keychain
|
||||||
// Recoverable error - use ephemeral key and warn
|
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
|
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now assign the signing keys
|
// Now assign the signing keys
|
||||||
@@ -297,56 +258,6 @@ final class NoiseEncryptionService {
|
|||||||
startRekeyTimer()
|
startRekeyTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
|
|
||||||
|
|
||||||
/// Generate and save a new Noise static key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
|
|
||||||
let newKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist noise static key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
|
|
||||||
let newKey = Curve25519.Signing.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist signing key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Get our static public key for sharing
|
/// Get our static public key for sharing
|
||||||
@@ -617,17 +528,6 @@ final class NoiseEncryptionService {
|
|||||||
rateLimiter.resetAll()
|
rateLimiter.resetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
|
||||||
func clearSession(for peerID: PeerID) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
@@ -646,7 +546,7 @@ final class NoiseEncryptionService {
|
|||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peerID.id, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport, @unchecked Sendable {
|
final class NostrTransport: Transport {
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID = PeerID(str: "")
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
@@ -18,49 +18,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
|
||||||
private var reachablePeers: Set<PeerID> = []
|
|
||||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
|
|
||||||
setupObservers()
|
|
||||||
|
|
||||||
// Synchronously warm the cache to avoid startup race
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
|
||||||
let reachable = favorites.values
|
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.reachablePeers = Set(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
queue: nil
|
|
||||||
) { [weak self] _ in
|
|
||||||
self?.refreshReachablePeers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshReachablePeers() {
|
|
||||||
Task { @MainActor in
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
|
||||||
let reachable = favorites.values
|
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
||||||
|
|
||||||
self.queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.reachablePeers = Set(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -82,19 +42,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
|
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
|
||||||
queue.sync {
|
|
||||||
// Check if exact match
|
|
||||||
if reachablePeers.contains(peerID) { return true }
|
|
||||||
// Check for short ID match
|
|
||||||
if peerID.isShort {
|
|
||||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||||
|
|
||||||
@@ -118,54 +66,89 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
// Convert recipient npub -> hex (x-only)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else {
|
||||||
|
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
// Use barrier to synchronize access to readQueue
|
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
processReadQueueIfNeeded()
|
||||||
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
|
||||||
self?.processReadQueueIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
let recipientHex: String
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,17 +160,21 @@ extension NostrTransport {
|
|||||||
// MARK: Geohash ACK helpers
|
// MARK: Geohash ACK helpers
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,12 +182,19 @@ extension NostrTransport {
|
|||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard !recipientHex.isEmpty else { return }
|
guard !recipientHex.isEmpty else { return }
|
||||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,63 +202,46 @@ extension NostrTransport {
|
|||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
extension NostrTransport {
|
extension NostrTransport {
|
||||||
/// Converts npub bech32 string to hex pubkey
|
|
||||||
@MainActor
|
|
||||||
private func npubToHex(_ npub: String) -> String? {
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(npub)
|
|
||||||
guard hrp == "npub" else { return nil }
|
|
||||||
return data.hexEncodedString()
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates and sends a gift-wrapped private message event
|
|
||||||
@MainActor
|
|
||||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if registerPending {
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
}
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Must be called within a barrier on `queue`
|
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
guard !readQueue.isEmpty else { return }
|
guard !readQueue.isEmpty else { return }
|
||||||
isSendingReadAcks = true
|
isSendingReadAcks = true
|
||||||
let item = readQueue.removeFirst()
|
sendNextReadAck()
|
||||||
sendReadAckItem(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
private func sendNextReadAck() {
|
||||||
private func sendReadAckItem(_ item: QueuedRead) {
|
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||||
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { scheduleNextReadAck() }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
// Convert recipient npub -> hex
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
let recipientHex: String
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { scheduleNextReadAck(); return }
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
|
||||||
|
scheduleNextReadAck(); return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
scheduleNextReadAck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextReadAck() {
|
private func scheduleNextReadAck() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||||
self?.queue.async(flags: .barrier) { [weak self] in
|
guard let self = self else { return }
|
||||||
self?.isSendingReadAcks = false
|
self.isSendingReadAcks = false
|
||||||
self?.processReadQueueIfNeeded()
|
self.processReadQueueIfNeeded()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,9 @@ import AppKit
|
|||||||
final class NotificationService {
|
final class NotificationService {
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
|
||||||
private var isRunningTests: Bool {
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
guard !isRunningTests else { return }
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
@@ -40,31 +29,28 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(
|
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
||||||
title: String,
|
// For now, skip app state check entirely to avoid thread issues
|
||||||
body: String,
|
// The NotificationDelegate will handle foreground presentation
|
||||||
identifier: String,
|
DispatchQueue.main.async {
|
||||||
userInfo: [String: Any]? = nil,
|
let content = UNMutableNotificationContent()
|
||||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
content.title = title
|
||||||
) {
|
content.body = body
|
||||||
guard !isRunningTests else { return }
|
content.sound = .default
|
||||||
let content = UNMutableNotificationContent()
|
if let userInfo = userInfo {
|
||||||
content.title = title
|
content.userInfo = userInfo
|
||||||
content.body = body
|
}
|
||||||
content.sound = .default
|
|
||||||
content.interruptionLevel = interruptionLevel
|
|
||||||
|
|
||||||
if let userInfo = userInfo {
|
let request = UNNotificationRequest(
|
||||||
content.userInfo = userInfo
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
|
||||||
content: content,
|
|
||||||
trigger: nil // Deliver immediately
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -75,11 +61,11 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 DM from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
@@ -98,11 +84,24 @@ final class NotificationService {
|
|||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
|
|
||||||
sendLocalNotification(
|
// For network notifications, we want to show them even in foreground
|
||||||
title: title,
|
// No app state check - let the notification delegate handle presentation
|
||||||
body: body,
|
DispatchQueue.main.async {
|
||||||
identifier: identifier,
|
let content = UNMutableNotificationContent()
|
||||||
interruptionLevel: .timeSensitive
|
content.title = title
|
||||||
)
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
content.interruptionLevel = .timeSensitive // Make it more prominent
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ struct NotificationStreamAssembler {
|
|||||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
|
|
||||||
|
|
||||||
let lengthOffset = 12
|
let lengthOffset = 12
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
@@ -81,15 +80,6 @@ struct NotificationStreamAssembler {
|
|||||||
var frameLength = framePrefix + payloadLength
|
var frameLength = framePrefix + payloadLength
|
||||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
|
|
||||||
let routeCountIndex = buffer.startIndex + routeCountOffset
|
|
||||||
guard buffer.count > routeCountOffset else { break }
|
|
||||||
let routeCount = Int(buffer[routeCountIndex])
|
|
||||||
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||||
if payloadLength < rawLengthFieldBytes {
|
if payloadLength < rawLengthFieldBytes {
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
// Peer service for looking up peer info during consolidation
|
|
||||||
weak var unifiedPeerService: UnifiedPeerService?
|
|
||||||
|
|
||||||
init(meshService: Transport? = nil) {
|
init(meshService: Transport? = nil) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
@@ -32,158 +30,6 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
// MARK: - Message Consolidation
|
|
||||||
|
|
||||||
/// Consolidates messages from different peer ID representations into a single chat.
|
|
||||||
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peerID: The target peer ID to consolidate messages into
|
|
||||||
/// - peerNickname: The peer's display name (lowercased for matching)
|
|
||||||
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
|
|
||||||
/// - Returns: True if any unread messages were found during consolidation
|
|
||||||
@MainActor
|
|
||||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
|
||||||
guard let meshService = meshService else { return false }
|
|
||||||
var hasUnreadMessages = false
|
|
||||||
|
|
||||||
// 1. Consolidate from stable Noise key (64-char hex)
|
|
||||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
|
|
||||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
for message in nostrMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
// Update senderPeerID for correct read receipts
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
|
|
||||||
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
|
||||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
|
||||||
if message.senderPeerID != meshService.myPeerID {
|
|
||||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
|
||||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
|
||||||
hasUnreadMessages = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
if hasUnreadMessages {
|
|
||||||
unreadMessages.insert(peerID)
|
|
||||||
} else if unreadMessages.contains(noiseKeyHex) {
|
|
||||||
unreadMessages.remove(noiseKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChats.removeValue(forKey: noiseKeyHex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
|
||||||
let normalizedNickname = peerNickname.lowercased()
|
|
||||||
var tempPeerIDsToConsolidate: [PeerID] = []
|
|
||||||
|
|
||||||
for (storedPeerID, messages) in privateChats {
|
|
||||||
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
|
||||||
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
|
||||||
if nicknamesMatch && !messages.isEmpty {
|
|
||||||
tempPeerIDsToConsolidate.append(storedPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tempPeerIDsToConsolidate.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
var consolidatedCount = 0
|
|
||||||
var hadUnreadTemp = false
|
|
||||||
|
|
||||||
for tempPeerID in tempPeerIDsToConsolidate {
|
|
||||||
if unreadMessages.contains(tempPeerID) {
|
|
||||||
hadUnreadTemp = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tempMessages = privateChats[tempPeerID] {
|
|
||||||
for message in tempMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
consolidatedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
privateChats.removeValue(forKey: tempPeerID)
|
|
||||||
unreadMessages.remove(tempPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hadUnreadTemp {
|
|
||||||
unreadMessages.insert(peerID)
|
|
||||||
hasUnreadMessages = true
|
|
||||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
if consolidatedCount > 0 {
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasUnreadMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Syncs the read receipt tracking between manager and view model for sent messages
|
|
||||||
@MainActor
|
|
||||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
|
||||||
guard let messages = privateChats[peerID] else { return }
|
|
||||||
|
|
||||||
for message in messages {
|
|
||||||
if message.sender == nickname {
|
|
||||||
if let status = message.deliveryStatus {
|
|
||||||
switch status {
|
|
||||||
case .read, .delivered:
|
|
||||||
externalReceipts.insert(message.id)
|
|
||||||
sentReadReceipts.insert(message.id)
|
|
||||||
case .failed, .partiallyDelivered, .sending, .sent:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
@@ -259,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
readerID: meshService?.myPeerID.id ?? "",
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ struct RelayController {
|
|||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isFragment: Bool,
|
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
@@ -37,16 +36,6 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isFragment {
|
|
||||||
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
|
||||||
guard ttlLimit > 1 else {
|
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
|
||||||
}
|
|
||||||
let newTTL = ttlLimit &- 1
|
|
||||||
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
|
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Announces get a bit more headroom
|
// - Announces get a bit more headroom
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
@@ -58,10 +57,6 @@ protocol Transport: AnyObject {
|
|||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
|
||||||
func acceptPendingFile(id: String) -> URL?
|
|
||||||
func declinePendingFile(id: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
@@ -70,13 +65,6 @@ extension Transport {
|
|||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
||||||
sendMessage(content, mentions: mentions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptPendingFile(id: String) -> URL? { nil }
|
|
||||||
func declinePendingFile(id: String) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ enum TransportConfig {
|
|||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
|
||||||
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
|
||||||
|
|
||||||
// UI / Storage Caps
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
@@ -70,7 +66,6 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
@@ -96,12 +91,11 @@ enum TransportConfig {
|
|||||||
// Keep scanning fully ON when we saw traffic very recently
|
// Keep scanning fully ON when we saw traffic very recently
|
||||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||||
static let bleExpectedWriteMaxMs: Int = 5000
|
static let bleExpectedWriteMaxMs: Int = 2000
|
||||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
static let bleFragmentSpacingMs: Int = 5
|
||||||
static let bleFragmentSpacingMs: Int = 30
|
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
|
||||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||||
@@ -151,9 +145,6 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Geo relay directory
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
|
||||||
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
|
||||||
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
|
||||||
|
|
||||||
// BLE operational delays
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
@@ -163,14 +154,6 @@ enum TransportConfig {
|
|||||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||||
|
|
||||||
// BCH-01-004: Rate-limiting for subscription-triggered announces
|
|
||||||
// Prevents rapid enumeration attacks by rate-limiting announce responses
|
|
||||||
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
|
|
||||||
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
|
|
||||||
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
|
|
||||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
|
||||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
|
||||||
|
|
||||||
// Store-and-forward for directed packets at relays
|
// Store-and-forward for directed packets at relays
|
||||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||||
|
|
||||||
@@ -212,18 +195,4 @@ enum TransportConfig {
|
|||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
// Gossip Sync Configuration
|
|
||||||
static let syncSeenCapacity: Int = 1000
|
|
||||||
static let syncGCSMaxBytes: Int = 400
|
|
||||||
static let syncGCSTargetFpr: Double = 0.01
|
|
||||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
|
||||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
|
||||||
static let syncFragmentCapacity: Int = 600
|
|
||||||
static let syncFileTransferCapacity: Int = 200
|
|
||||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
// Gossip-based sync manager using on-demand GCS filters
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
@@ -7,56 +6,6 @@ final class GossipSyncManager {
|
|||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
func getConnectedPeers() -> [PeerID]
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PacketStore {
|
|
||||||
private(set) var packets: [String: BitchatPacket] = [:]
|
|
||||||
private(set) var order: [String] = []
|
|
||||||
|
|
||||||
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
|
|
||||||
guard capacity > 0 else { return }
|
|
||||||
if packets[idHex] != nil {
|
|
||||||
packets[idHex] = packet
|
|
||||||
return
|
|
||||||
}
|
|
||||||
packets[idHex] = packet
|
|
||||||
order.append(idHex)
|
|
||||||
while order.count > capacity {
|
|
||||||
let victim = order.removeFirst()
|
|
||||||
packets.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
|
|
||||||
order.compactMap { key in
|
|
||||||
guard let packet = packets[key], isFresh(packet) else { return nil }
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
|
|
||||||
var nextOrder: [String] = []
|
|
||||||
for key in order {
|
|
||||||
guard let packet = packets[key] else { continue }
|
|
||||||
if shouldRemove(packet) {
|
|
||||||
packets.removeValue(forKey: key)
|
|
||||||
} else {
|
|
||||||
nextOrder.append(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
order = nextOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
|
|
||||||
remove { !isFresh($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SyncSchedule {
|
|
||||||
let types: SyncTypeFlags
|
|
||||||
let interval: TimeInterval
|
|
||||||
var lastSent: Date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
@@ -67,45 +16,25 @@ final class GossipSyncManager {
|
|||||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
var fragmentCapacity: Int = 600
|
|
||||||
var fileTransferCapacity: Int = 200
|
|
||||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
|
||||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
|
||||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
private let requestSyncManager: RequestSyncManager
|
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast packets by type, and latest announce per sender
|
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
||||||
private var messages = PacketStore()
|
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
||||||
private var fragments = PacketStore()
|
private var messageOrder: [String] = []
|
||||||
private var fileTransfers = PacketStore()
|
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
||||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
private var lastStalePeerCleanup: Date = .distantPast
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
private var syncSchedules: [SyncSchedule] = []
|
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
self.requestSyncManager = requestSyncManager
|
|
||||||
var schedules: [SyncSchedule] = []
|
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
|
||||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
|
||||||
}
|
|
||||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
|
||||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
|
||||||
}
|
|
||||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
|
||||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
|
||||||
}
|
|
||||||
syncSchedules = schedules
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -126,18 +55,7 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
guard let self = self else { return }
|
self?.sendRequestSync(to: peerID)
|
||||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
|
||||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
|
||||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
||||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
|
||||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
||||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,58 +87,47 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
}()
|
}()
|
||||||
|
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
||||||
|
let isAnnounce = (mt == .announce)
|
||||||
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
switch messageType {
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
case .announce:
|
guard isPacketFresh(packet) else { return }
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
|
if isAnnounce {
|
||||||
guard isAnnouncementFresh(packet) else {
|
guard isAnnouncementFresh(packet) else {
|
||||||
let sender = PeerID(hexData: packet.senderID)
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
removeState(for: sender)
|
removeState(forNormalizedPeerID: sender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
let sender = PeerID(hexData: packet.senderID)
|
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
|
||||||
case .message:
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
|
||||||
case .fragment:
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
|
|
||||||
case .fileTransfer:
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
// Unicast sync to connected peers to allow RSR attribution
|
|
||||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
if isBroadcastMessage {
|
||||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
if messages[idHex] == nil {
|
||||||
for peerID in connectedPeers {
|
messages[idHex] = packet
|
||||||
sendRequestSync(to: peerID, types: types)
|
messageOrder.append(idHex)
|
||||||
|
// Enforce capacity
|
||||||
|
let cap = max(1, config.seenCapacity)
|
||||||
|
while messageOrder.count > cap {
|
||||||
|
let victim = messageOrder.removeFirst()
|
||||||
|
messages.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if isAnnounce {
|
||||||
// Fallback to broadcast (discovery phase)
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
sendRequestSync(for: types)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
private func sendRequestSync() {
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload()
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
@@ -234,11 +141,8 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
private func sendRequestSync(to peerID: PeerID) {
|
||||||
// Register the request for RSR validation
|
let payload = buildGcsPayload()
|
||||||
requestSyncManager.registerRequest(to: peerID)
|
|
||||||
|
|
||||||
let payload = buildGcsPayload(for: types)
|
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
@@ -266,7 +170,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
let requestedTypes = (request.types ?? .publicMessages)
|
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -274,104 +177,60 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.announce) {
|
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
guard isPacketFresh(pkt) else { continue }
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.message) {
|
// 2) Broadcast messages: send all missing (and not expired)
|
||||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
guard isPacketFresh(pkt) else { continue }
|
||||||
if !mightContain(idBytes) {
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
var toSend = pkt
|
if !mightContain(idBytes) {
|
||||||
toSend.ttl = 0
|
var toSend = pkt
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedTypes.contains(.fragment) {
|
|
||||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
|
||||||
for pkt in frags {
|
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
|
||||||
if !mightContain(idBytes) {
|
|
||||||
var toSend = pkt
|
|
||||||
toSend.ttl = 0
|
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedTypes.contains(.fileTransfer) {
|
|
||||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
|
||||||
for pkt in files {
|
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
|
||||||
if !mightContain(idBytes) {
|
|
||||||
var toSend = pkt
|
|
||||||
toSend.ttl = 0
|
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
private func buildGcsPayload() -> Data {
|
||||||
|
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
if types.contains(.announce) {
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
|
if isPacketFresh(pair.packet) {
|
||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if types.contains(.message) {
|
for id in messageOrder {
|
||||||
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
if let p = messages[id], isPacketFresh(p) {
|
||||||
|
candidates.append(p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if types.contains(.fragment) {
|
|
||||||
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
|
|
||||||
}
|
|
||||||
if types.contains(.fileTransfer) {
|
|
||||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
|
||||||
}
|
|
||||||
if candidates.isEmpty {
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
|
||||||
return req.encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by timestamp desc
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
let cap: Int
|
let cap = max(1, config.seenCapacity)
|
||||||
if types == .fragment {
|
|
||||||
cap = max(1, config.fragmentCapacity)
|
|
||||||
} else if types == .fileTransfer {
|
|
||||||
cap = max(1, config.fileTransferCapacity)
|
|
||||||
} else {
|
|
||||||
cap = max(1, config.seenCapacity)
|
|
||||||
}
|
|
||||||
let takeN = min(candidates.count, min(nMax, cap))
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
if takeN <= 0 {
|
if takeN <= 0 {
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,23 +241,20 @@ final class GossipSyncManager {
|
|||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.removeExpired(isFresh: isPacketFresh)
|
// Remove expired messages
|
||||||
fragments.removeExpired(isFresh: isPacketFresh)
|
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
isPacketFresh(pkt) ? nil : id
|
||||||
|
}
|
||||||
|
for id in expiredMessageIds {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
sendRequestSync()
|
||||||
|
|
||||||
for index in syncSchedules.indices {
|
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
|
||||||
syncSchedules[index].lastSent = now
|
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -414,27 +270,40 @@ final class GossipSyncManager {
|
|||||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
guard nowMs >= timeoutMs else { return }
|
guard nowMs >= timeoutMs else { return }
|
||||||
let cutoff = nowMs - timeoutMs
|
let cutoff = nowMs - timeoutMs
|
||||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||||
pair.packet.timestamp < cutoff ? peerID : nil
|
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||||
}
|
}
|
||||||
guard !stalePeerIDs.isEmpty else { return }
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
for peerKey in stalePeerIDs {
|
for peerKey in stalePeerIDs {
|
||||||
removeState(for: peerKey)
|
removeState(forNormalizedPeerID: peerKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?.removeState(for: peerID)
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeState(for peerID: PeerID) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
}
|
||||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
|
||||||
|
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
// Remove messages from this peer
|
||||||
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
|
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove messages and update messageOrder
|
||||||
|
for id in messageIdsToRemove {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,13 +317,13 @@ extension GossipSyncManager {
|
|||||||
|
|
||||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
latestAnnouncementByPeer[peerID] != nil
|
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
//
|
|
||||||
// RequestSyncManager.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Manages outgoing sync requests and validates incoming responses.
|
|
||||||
///
|
|
||||||
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
|
|
||||||
/// that we have actively requested sync from.
|
|
||||||
final class RequestSyncManager {
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
|
||||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
|
||||||
|
|
||||||
// Allow responses for 30s after request
|
|
||||||
private let responseWindow: TimeInterval = 30.0
|
|
||||||
|
|
||||||
/// Register that we are sending a sync request to a peer.
|
|
||||||
/// - Parameter peerID: The peer we are requesting sync from
|
|
||||||
func registerRequest(to peerID: PeerID) {
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
|
||||||
self.pendingRequests[peerID] = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a packet from a peer is a valid response to a sync request.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peerID: The sender of the packet
|
|
||||||
/// - isRSR: Whether the packet is marked as a Request-Sync Response
|
|
||||||
/// - Returns: true if we have a pending request for this peer and the window is open
|
|
||||||
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
|
|
||||||
guard isRSR else { return false }
|
|
||||||
|
|
||||||
return queue.sync {
|
|
||||||
guard let requestTime = pendingRequests[peerID] else {
|
|
||||||
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
if now - requestTime > responseWindow {
|
|
||||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
|
||||||
// We don't remove here because we might receive multiple packets for one request
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Periodic cleanup of expired requests
|
|
||||||
func cleanup() {
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let originalCount = self.pendingRequests.count
|
|
||||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
|
||||||
now - timestamp <= self.responseWindow
|
|
||||||
}
|
|
||||||
let removed = originalCount - self.pendingRequests.count
|
|
||||||
if removed > 0 {
|
|
||||||
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
|
||||||
/// Matches the Android mapping (bit index -> message type).
|
|
||||||
struct SyncTypeFlags: OptionSet {
|
|
||||||
let rawValue: UInt64
|
|
||||||
|
|
||||||
init(rawValue: UInt64) {
|
|
||||||
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func bitIndex(for type: MessageType) -> Int? {
|
|
||||||
switch type {
|
|
||||||
case .announce: return 0
|
|
||||||
case .message: return 1
|
|
||||||
case .leave: return 2
|
|
||||||
case .noiseHandshake: return 3
|
|
||||||
case .noiseEncrypted: return 4
|
|
||||||
case .fragment: return 5
|
|
||||||
case .requestSync: return 6
|
|
||||||
case .fileTransfer: return 7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func type(forBit index: Int) -> MessageType? {
|
|
||||||
switch index {
|
|
||||||
case 0: return .announce
|
|
||||||
case 1: return .message
|
|
||||||
case 2: return .leave
|
|
||||||
case 3: return .noiseHandshake
|
|
||||||
case 4: return .noiseEncrypted
|
|
||||||
case 5: return .fragment
|
|
||||||
case 6: return .requestSync
|
|
||||||
case 7: return .fileTransfer
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let announce = SyncTypeFlags(messageTypes: [.announce])
|
|
||||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
|
||||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
|
||||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
|
||||||
|
|
||||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
|
||||||
|
|
||||||
init(messageTypes: [MessageType]) {
|
|
||||||
var raw: UInt64 = 0
|
|
||||||
for type in messageTypes {
|
|
||||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
|
|
||||||
raw |= (1 << UInt64(bit))
|
|
||||||
}
|
|
||||||
self.init(rawValue: raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(_ type: MessageType) -> Bool {
|
|
||||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
|
|
||||||
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
|
||||||
SyncTypeFlags(rawValue: rawValue | other.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
|
||||||
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMessageTypes() -> [MessageType] {
|
|
||||||
guard rawValue != 0 else { return [] }
|
|
||||||
var types: [MessageType] = []
|
|
||||||
for bit in 0..<64 {
|
|
||||||
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
|
|
||||||
if let type = SyncTypeFlags.type(forBit: bit) {
|
|
||||||
types.append(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return types
|
|
||||||
}
|
|
||||||
|
|
||||||
func toData() -> Data? {
|
|
||||||
guard rawValue != 0 else { return nil }
|
|
||||||
var value = rawValue
|
|
||||||
var bytes: [UInt8] = []
|
|
||||||
while value > 0 && bytes.count < 8 {
|
|
||||||
bytes.append(UInt8(value & 0xFF))
|
|
||||||
value >>= 8
|
|
||||||
}
|
|
||||||
while let last = bytes.last, last == 0 {
|
|
||||||
bytes.removeLast()
|
|
||||||
}
|
|
||||||
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
|
|
||||||
return Data(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ data: Data) -> SyncTypeFlags? {
|
|
||||||
guard (1...8).contains(data.count) else { return nil }
|
|
||||||
var raw: UInt64 = 0
|
|
||||||
for (index, byte) in data.enumerated() {
|
|
||||||
raw |= UInt64(byte) << UInt64(index * 8)
|
|
||||||
}
|
|
||||||
return SyncTypeFlags(rawValue: raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,12 +62,14 @@ struct CompressionUtil {
|
|||||||
// 2. Data appears to be already compressed (high entropy)
|
// 2. Data appears to be already compressed (high entropy)
|
||||||
guard data.count >= compressionThreshold else { return false }
|
guard data.count >= compressionThreshold else { return false }
|
||||||
|
|
||||||
// Quick uniqueness check — a high diversity of bytes usually means the
|
// Simple entropy check - count unique bytes
|
||||||
// payload is already compressed. We only need to know how many unique
|
var byteFrequency = [UInt8: Int]()
|
||||||
// values exist rather than keeping full frequency counts.
|
for byte in data {
|
||||||
let uniqueByteCount = Set(data).count
|
byteFrequency[byte, default: 0] += 1
|
||||||
let sampleSize = min(data.count, 256)
|
}
|
||||||
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
|
||||||
|
// If we have very high byte diversity, data is likely already compressed
|
||||||
|
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
|
||||||
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ enum FileTransferLimits {
|
|||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
static let maxFramedFileBytes: Int = {
|
static let maxFramedFileBytes: Int = {
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Comprehensive input validation for BitChat protocol
|
/// Comprehensive input validation for BitChat protocol
|
||||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||||
@@ -17,28 +16,29 @@ struct InputValidator {
|
|||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings used in UI
|
/// Validates and sanitizes user-provided strings used in UI
|
||||||
///
|
|
||||||
/// Rejects strings containing control characters to prevent potential security issues
|
|
||||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
|
||||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
|
// Check empty
|
||||||
|
guard !string.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Check length
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Reject control characters outright instead of rewriting the string.
|
// Remove control characters
|
||||||
// This prevents injection attacks and ensures consistent UI rendering.
|
|
||||||
let controlChars = CharacterSet.controlCharacters
|
let controlChars = CharacterSet.controlCharacters
|
||||||
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||||
// Log rejection for monitoring, without exposing actual content for privacy
|
|
||||||
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
|
||||||
SecureLogger.debug(
|
|
||||||
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
|
||||||
category: .security
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed
|
// Ensure valid UTF-8 (should already be, but double-check)
|
||||||
|
guard cleaned.data(using: .utf8) != nil else { return nil }
|
||||||
|
|
||||||
|
// Prevent zero-width characters and other invisible unicode
|
||||||
|
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
||||||
|
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
||||||
|
|
||||||
|
return visible.isEmpty ? nil : visible
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
@@ -52,13 +52,11 @@ struct InputValidator {
|
|||||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||||
|
|
||||||
/// Validates timestamp is reasonable (not too far in past or future)
|
/// Validates timestamp is reasonable (not too far in past or future)
|
||||||
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
|
|
||||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
// 5 minutes = 300 seconds (industry standard for replay protection)
|
let oneHourAgo = now.addingTimeInterval(-3600)
|
||||||
let fiveMinutesAgo = now.addingTimeInterval(-300)
|
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||||
let fiveMinutesFromNow = now.addingTimeInterval(300)
|
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||||
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,112 +2,66 @@ import Foundation
|
|||||||
|
|
||||||
// MARK: - Message Deduplicator (shared)
|
// MARK: - Message Deduplicator (shared)
|
||||||
|
|
||||||
/// Thread-safe deduplicator with LRU eviction and time-based expiry.
|
|
||||||
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
|
|
||||||
final class MessageDeduplicator {
|
final class MessageDeduplicator {
|
||||||
private struct Entry: Equatable {
|
private struct Entry {
|
||||||
let id: String
|
let messageID: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [Entry] = []
|
private var entries: [Entry] = []
|
||||||
private var head: Int = 0
|
private var head: Int = 0
|
||||||
private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup
|
private var lookup = Set<String>()
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let maxAge: TimeInterval
|
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||||
private let maxCount: Int
|
private let maxCount = TransportConfig.messageDedupMaxCount
|
||||||
|
|
||||||
/// Initialize with default config from TransportConfig
|
/// Check if message is duplicate and add if not
|
||||||
convenience init() {
|
func isDuplicate(_ messageID: String) -> Bool {
|
||||||
self.init(
|
|
||||||
maxAge: TransportConfig.messageDedupMaxAgeSeconds,
|
|
||||||
maxCount: TransportConfig.messageDedupMaxCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize with custom config for content deduplication
|
|
||||||
init(maxAge: TimeInterval, maxCount: Int) {
|
|
||||||
self.maxAge = maxAge
|
|
||||||
self.maxCount = maxCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if message is duplicate and add if not.
|
|
||||||
/// - Parameter id: The message identifier to check.
|
|
||||||
/// - Returns: `true` if the message was already seen, `false` otherwise.
|
|
||||||
func isDuplicate(_ id: String) -> Bool {
|
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|
||||||
let now = Date()
|
cleanupOldEntries()
|
||||||
cleanupOldEntries(before: now.addingTimeInterval(-maxAge))
|
|
||||||
|
|
||||||
if lookup[id] != nil {
|
if lookup.contains(messageID) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.append(Entry(id: id, timestamp: now))
|
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||||
lookup[id] = now
|
lookup.insert(messageID)
|
||||||
trimIfNeeded()
|
|
||||||
|
// Soft-cap and advance head by a chunk to avoid O(n) shifting
|
||||||
|
if (entries.count - head) > maxCount {
|
||||||
|
let removeCount = min(100, entries.count - head)
|
||||||
|
for i in head..<(head + removeCount) {
|
||||||
|
lookup.remove(entries[i].messageID)
|
||||||
|
}
|
||||||
|
head += removeCount
|
||||||
|
// Periodically compact to reclaim memory
|
||||||
|
if head > entries.count / 2 {
|
||||||
|
entries.removeFirst(head)
|
||||||
|
head = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an ID with a specific timestamp (for content key tracking)
|
|
||||||
func record(_ id: String, timestamp: Date) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
|
|
||||||
if lookup[id] == nil {
|
|
||||||
entries.append(Entry(id: id, timestamp: timestamp))
|
|
||||||
}
|
|
||||||
lookup[id] = timestamp
|
|
||||||
trimIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an ID without checking (for announce-back tracking)
|
/// Add an ID without checking (for announce-back tracking)
|
||||||
func markProcessed(_ id: String) {
|
func markProcessed(_ messageID: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|
||||||
if lookup[id] == nil {
|
if !lookup.contains(messageID) {
|
||||||
let now = Date()
|
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||||
entries.append(Entry(id: id, timestamp: now))
|
lookup.insert(messageID)
|
||||||
lookup[id] = now
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if ID exists without adding
|
/// Check if ID exists without adding
|
||||||
func contains(_ id: String) -> Bool {
|
func contains(_ messageID: String) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return lookup[id] != nil
|
return lookup.contains(messageID)
|
||||||
}
|
|
||||||
|
|
||||||
/// Get timestamp for an ID (for content deduplication time-window checks)
|
|
||||||
func timestampFor(_ id: String) -> Date? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return lookup[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func trimIfNeeded() {
|
|
||||||
let activeCount = entries.count - head
|
|
||||||
guard activeCount > maxCount else { return }
|
|
||||||
|
|
||||||
// Remove down to 75% of maxCount for better amortization
|
|
||||||
let targetCount = (maxCount * 3) / 4
|
|
||||||
let removeCount = activeCount - targetCount
|
|
||||||
|
|
||||||
for i in head..<(head + removeCount) {
|
|
||||||
lookup.removeValue(forKey: entries[i].id)
|
|
||||||
}
|
|
||||||
head += removeCount
|
|
||||||
|
|
||||||
// Compact when head exceeds half the array to reclaim memory
|
|
||||||
if head > entries.count / 2 {
|
|
||||||
entries.removeFirst(head)
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all entries
|
/// Clear all entries
|
||||||
@@ -120,25 +74,24 @@ final class MessageDeduplicator {
|
|||||||
lookup.removeAll()
|
lookup.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periodic cleanup of expired entries and memory optimization.
|
/// Periodic cleanup
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|
||||||
cleanupOldEntries(before: Date().addingTimeInterval(-maxAge))
|
cleanupOldEntries()
|
||||||
|
|
||||||
// Shrink capacity if significantly oversized
|
if entries.capacity > maxCount * 2 {
|
||||||
if entries.capacity > maxCount * 2 && entries.count < maxCount {
|
|
||||||
entries.reserveCapacity(maxCount)
|
entries.reserveCapacity(maxCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupOldEntries(before cutoff: Date) {
|
private func cleanupOldEntries() {
|
||||||
|
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||||
while head < entries.count, entries[head].timestamp < cutoff {
|
while head < entries.count, entries[head].timestamp < cutoff {
|
||||||
lookup.removeValue(forKey: entries[head].id)
|
lookup.remove(entries[head].messageID)
|
||||||
head += 1
|
head += 1
|
||||||
}
|
}
|
||||||
// Compact when head exceeds half the array
|
|
||||||
if head > 0 && head > entries.count / 2 {
|
if head > 0 && head > entries.count / 2 {
|
||||||
entries.removeFirst(head)
|
entries.removeFirst(head)
|
||||||
head = 0
|
head = 0
|
||||||
|
|||||||
+3231
-617
File diff suppressed because it is too large
Load Diff
@@ -1,836 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModel+Nostr.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Geohash and Nostr logic for ChatViewModel
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import SwiftUI
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
extension ChatViewModel {
|
|
||||||
|
|
||||||
// MARK: - Geohash Subscription
|
|
||||||
|
|
||||||
// Resubscribe to the active geohash channel without clearing timeline
|
|
||||||
@MainActor
|
|
||||||
func resubscribeCurrentGeohash() {
|
|
||||||
guard case .location(let ch) = activeChannel else { return }
|
|
||||||
guard let subID = geoSubscriptionID else {
|
|
||||||
// No existing subscription; set it up
|
|
||||||
switchLocationChannel(to: activeChannel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Ensure participant decay timer is running
|
|
||||||
participantTracker.startRefreshTimer()
|
|
||||||
// Unsubscribe + resubscribe
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
ch.geohash,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
|
||||||
limit: TransportConfig.nostrGeohashInitialLimit
|
|
||||||
)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: ch.geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
self?.subscribeNostrEvent(event)
|
|
||||||
}
|
|
||||||
// Resubscribe geohash DMs for this identity
|
|
||||||
if let dmSub = geoDmSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
|
||||||
let dmSub = "geo-dm-\(ch.geohash)"
|
|
||||||
geoDmSubscriptionID = dmSub
|
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
|
||||||
self?.subscribeGiftWrap(giftWrap, id: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
|
||||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deduplicationService.recordNostrEvent(event.id)
|
|
||||||
|
|
||||||
if let gh = currentGeohash,
|
|
||||||
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
|
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
|
||||||
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) < 15 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
||||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
|
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
|
||||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
|
||||||
})
|
|
||||||
|
|
||||||
if hasTeleportTag {
|
|
||||||
let key = event.pubkey.lowercased()
|
|
||||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
|
||||||
return my.publicKeyHex.lowercased() == key
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
if !isSelf {
|
|
||||||
Task { @MainActor in
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let timestamp = min(rawTs, Date())
|
|
||||||
let mentions = parseMentions(from: content)
|
|
||||||
let msg = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
Task { @MainActor in
|
|
||||||
// BCH-01-012: Check blocking before any notifications
|
|
||||||
// handlePublicMessage has its own blocking check but returns silently,
|
|
||||||
// so we must also guard checkForMentions to prevent notification bypass
|
|
||||||
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
|
||||||
|
|
||||||
handlePublicMessage(msg)
|
|
||||||
|
|
||||||
// Only check mentions and send haptic if sender is not blocked
|
|
||||||
if !isBlocked {
|
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
||||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
|
||||||
content.hasPrefix("bitchat1:"),
|
|
||||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
|
||||||
let packet = BitchatPacket.from(packetData),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let noisePayload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
nostrKeyMapping[convKey] = senderPubkey
|
|
||||||
|
|
||||||
switch noisePayload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Channel Handling
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
|
||||||
// Reset pending public batches to avoid cross-channel bleed
|
|
||||||
publicMessagePipeline.reset()
|
|
||||||
|
|
||||||
activeChannel = channel
|
|
||||||
publicMessagePipeline.updateActiveChannel(channel)
|
|
||||||
|
|
||||||
// Reset deduplication set and optionally hydrate timeline for mesh
|
|
||||||
deduplicationService.clearNostrCaches()
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
refreshVisibleMessages(from: .mesh)
|
|
||||||
// Debug: log if any empty messages are present
|
|
||||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
|
||||||
if emptyMesh > 0 {
|
|
||||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
|
||||||
}
|
|
||||||
participantTracker.stopRefreshTimer()
|
|
||||||
participantTracker.setActiveGeohash(nil)
|
|
||||||
teleportedGeo.removeAll()
|
|
||||||
case .location:
|
|
||||||
refreshVisibleMessages(from: channel)
|
|
||||||
}
|
|
||||||
// If switching to a location channel, flush any pending geohash-only system messages
|
|
||||||
if case .location = channel {
|
|
||||||
for content in timelineStore.drainPendingGeohashSystemMessages() {
|
|
||||||
addPublicSystemMessage(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Unsubscribe previous
|
|
||||||
if let sub = geoSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
|
||||||
geoSubscriptionID = nil
|
|
||||||
}
|
|
||||||
if let dmSub = geoDmSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
|
||||||
geoDmSubscriptionID = nil
|
|
||||||
}
|
|
||||||
currentGeohash = nil
|
|
||||||
participantTracker.setActiveGeohash(nil)
|
|
||||||
// Reset nickname cache for geochat participants
|
|
||||||
geoNicknames.removeAll()
|
|
||||||
|
|
||||||
guard case .location(let ch) = channel else { return }
|
|
||||||
currentGeohash = ch.geohash
|
|
||||||
participantTracker.setActiveGeohash(ch.geohash)
|
|
||||||
|
|
||||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
|
||||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
|
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
let key = id.publicKeyHex.lowercased()
|
|
||||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
|
||||||
} else {
|
|
||||||
teleportedGeo.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let subID = "geo-\(ch.geohash)"
|
|
||||||
geoSubscriptionID = subID
|
|
||||||
participantTracker.startRefreshTimer()
|
|
||||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
|
||||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
self?.handleNostrEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeToGeoChat(ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
|
||||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
|
||||||
|
|
||||||
// Deduplicate
|
|
||||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
|
||||||
deduplicationService.recordNostrEvent(event.id)
|
|
||||||
|
|
||||||
// Log incoming tags for diagnostics
|
|
||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
|
||||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
|
||||||
|
|
||||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
|
||||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
|
||||||
}
|
|
||||||
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
|
||||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
if hasTeleportTag {
|
|
||||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
|
||||||
if !isSelf {
|
|
||||||
let key = event.pubkey.lowercased()
|
|
||||||
Task { @MainActor in
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
|
||||||
if isSelf {
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) < 15 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache nickname from tag if present
|
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
||||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store mapping for geohash DM initiation
|
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
|
||||||
|
|
||||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
|
||||||
let content = event.content
|
|
||||||
|
|
||||||
// If this is a teleport presence event (no content), don't add to timeline
|
|
||||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
|
||||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp future timestamps
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let mentions = parseMentions(from: content)
|
|
||||||
let msg = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: min(rawTs, Date()),
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
handlePublicMessage(msg)
|
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
|
||||||
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
|
||||||
|
|
||||||
let dmSub = "geo-dm-\(ch.geohash)"
|
|
||||||
geoDmSubscriptionID = dmSub
|
|
||||||
// pared back logging: subscribe debug only
|
|
||||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
|
||||||
if TorManager.shared.isReady {
|
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
|
||||||
}
|
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
|
||||||
self?.handleGiftWrap(giftWrap, id: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
||||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
// Decrypt with per-geohash identity
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
|
||||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
|
||||||
|
|
||||||
guard content.hasPrefix("bitchat1:"),
|
|
||||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
|
||||||
let packet = BitchatPacket.from(packetData),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let payload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
nostrKeyMapping[convKey] = senderPubkey
|
|
||||||
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
|
|
||||||
// Explicitly list other cases so we get compile-time check if a new case is added in the future
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendGeohash(context: GeoOutgoingContext) {
|
|
||||||
let ch = context.channel
|
|
||||||
let event = context.event
|
|
||||||
let identity = context.identity
|
|
||||||
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: ch.geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if targetRelays.isEmpty {
|
|
||||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
|
||||||
} else {
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track ourselves as active participant
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
|
||||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
|
||||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
|
|
||||||
|
|
||||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
|
||||||
// Only when not in our regional set (and regional list is known)
|
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
|
|
||||||
if context.teleported && hasRegional && !inRegional {
|
|
||||||
let key = identity.publicKeyHex.lowercased()
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
deduplicationService.recordNostrEvent(event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sampling
|
|
||||||
|
|
||||||
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
|
|
||||||
@MainActor
|
|
||||||
func beginGeohashSampling(for geohashes: [String]) {
|
|
||||||
// Disable sampling when app is backgrounded (Tor is stopped there)
|
|
||||||
if !TorManager.shared.isForeground() {
|
|
||||||
endGeohashSampling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Determine which to add and which to remove
|
|
||||||
let desired = Set(geohashes)
|
|
||||||
let current = Set(geoSamplingSubs.values)
|
|
||||||
let toAdd = desired.subtracting(current)
|
|
||||||
let toRemove = current.subtracting(desired)
|
|
||||||
|
|
||||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
geoSamplingSubs.removeValue(forKey: subID)
|
|
||||||
}
|
|
||||||
|
|
||||||
for gh in toAdd {
|
|
||||||
subscribe(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribe(_ gh: String) {
|
|
||||||
let subID = "geo-sample-\(gh)"
|
|
||||||
geoSamplingSubs[subID] = gh
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
gh,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
|
||||||
limit: TransportConfig.nostrGeohashSampleLimit
|
|
||||||
)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
self?.subscribeNostrEvent(event, gh: gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
|
||||||
|
|
||||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
|
||||||
let existingCount = participantTracker.participantCount(for: gh)
|
|
||||||
|
|
||||||
// Update participants for this specific geohash
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
|
||||||
|
|
||||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !content.isEmpty else { return }
|
|
||||||
|
|
||||||
// Respect geohash blocks
|
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
|
||||||
|
|
||||||
// Skip self identity for this geohash
|
|
||||||
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
|
||||||
|
|
||||||
// Only trigger when there were zero participants in this geohash recently
|
|
||||||
guard existingCount == 0 else { return }
|
|
||||||
|
|
||||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
|
||||||
|
|
||||||
// Foreground-only notifications: app must be active, and not already viewing this geohash
|
|
||||||
#if os(iOS)
|
|
||||||
guard UIApplication.shared.applicationState == .active else { return }
|
|
||||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
|
||||||
#elseif os(macOS)
|
|
||||||
guard NSApplication.shared.isActive else { return }
|
|
||||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
cooldownPerGeohash(gh, content: content, event: event)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
|
||||||
let now = Date()
|
|
||||||
let last = lastGeoNotificationAt[gh] ?? .distantPast
|
|
||||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
|
||||||
|
|
||||||
// Compose a short preview
|
|
||||||
let preview: String = {
|
|
||||||
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
|
||||||
if content.count <= maxLen { return content }
|
|
||||||
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
|
||||||
return String(content[..<idx]) + "…"
|
|
||||||
}()
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
lastGeoNotificationAt[gh] = now
|
|
||||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
|
||||||
let senderSuffix = String(event.pubkey.suffix(4))
|
|
||||||
let nick = geoNicknames[event.pubkey.lowercased()]
|
|
||||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
|
||||||
|
|
||||||
// Clamp future timestamps
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let ts = min(rawTs, Date())
|
|
||||||
let mentions = self.parseMentions(from: content)
|
|
||||||
let msg = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: ts,
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
|
|
||||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop sampling all extra geohashes.
|
|
||||||
@MainActor
|
|
||||||
func endGeohashSampling() {
|
|
||||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
|
||||||
geoSamplingSubs.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr DM Handling
|
|
||||||
|
|
||||||
func setupNostrMessageHandling() {
|
|
||||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
|
|
||||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
|
|
||||||
|
|
||||||
// Subscribe to Nostr messages
|
|
||||||
let filter = NostrFilter.giftWrapsFor(
|
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
|
||||||
)
|
|
||||||
|
|
||||||
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
|
||||||
self?.handleNostrMessage(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
|
||||||
// Deduplicate messages by ID
|
|
||||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
// Ensure we're on a background queue for decryption
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
await self?.processNostrMessage(giftWrap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
|
||||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: currentIdentity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle verification payloads first
|
|
||||||
if content.hasPrefix("verify:") {
|
|
||||||
// Ignore verification payloads arriving via Nostr path for now
|
|
||||||
// Verification should ideally happen over mesh for security binding
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
|
||||||
if content.hasPrefix("bitchat1:") {
|
|
||||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
|
||||||
let packet = BitchatPacket.from(packetData) else {
|
|
||||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map sender by Nostr pubkey to Noise key when possible
|
|
||||||
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
|
|
||||||
|
|
||||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
|
||||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
|
||||||
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
|
||||||
if let payload = NoisePayload.decode(packet.payload) {
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
|
||||||
// Store Nostr mapping
|
|
||||||
await MainActor.run {
|
|
||||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
|
||||||
|
|
||||||
// Handle packet types
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
||||||
case .readReceipt:
|
|
||||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
|
||||||
// Check favorites for this Nostr key
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
|
||||||
var npubToMatch = nostrPubkey
|
|
||||||
|
|
||||||
// Convert hex to npub if needed for comparison
|
|
||||||
if !nostrPubkey.hasPrefix("npub") {
|
|
||||||
if let pubkeyData = Data(hexString: nostrPubkey),
|
|
||||||
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
|
||||||
npubToMatch = encoded
|
|
||||||
} else {
|
|
||||||
SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for relationship in favorites {
|
|
||||||
// Search through favorites for matching Nostr pubkey
|
|
||||||
if let storedNostrKey = relationship.peerNostrPublicKey {
|
|
||||||
// Compare against stored key (could be hex or npub)
|
|
||||||
if storedNostrKey == npubToMatch {
|
|
||||||
// SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session)
|
|
||||||
return relationship.peerNoisePublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try comparing raw hex if stored key is hex
|
|
||||||
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
|
||||||
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
|
||||||
return relationship.peerNoisePublicKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
|
|
||||||
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
|
|
||||||
if let _ = key {
|
|
||||||
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
|
|
||||||
// check if we have an identity
|
|
||||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
}
|
|
||||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same for READ receipt if viewing
|
|
||||||
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
|
|
||||||
if let _ = key {
|
|
||||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
}
|
|
||||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
|
||||||
// Try to find Noise key associated with this Nostr pubkey
|
|
||||||
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
|
|
||||||
|
|
||||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
|
||||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
|
||||||
|
|
||||||
// Update favorite status
|
|
||||||
if isFavorite {
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
peerNostrPublicKey: nostrPubkey,
|
|
||||||
peerNickname: senderNickname
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Only remove if we don't have it set locally
|
|
||||||
// Logic handled by persistence service usually, here we just update remote state
|
|
||||||
// Actually for now we just process the notification
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract Nostr public key if included
|
|
||||||
var extractedNostrPubkey: String? = nil
|
|
||||||
if let range = content.range(of: "NPUB:") {
|
|
||||||
let suffix = content[range.upperBound...]
|
|
||||||
let parts = suffix.components(separatedBy: "|")
|
|
||||||
if let key = parts.first {
|
|
||||||
extractedNostrPubkey = String(key)
|
|
||||||
}
|
|
||||||
} else if content.contains(":") {
|
|
||||||
// Fallback: simple format FAVORITE:TRUE:npub...
|
|
||||||
let parts = content.components(separatedBy: ":")
|
|
||||||
if parts.count >= 3 {
|
|
||||||
extractedNostrPubkey = String(parts[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
|
||||||
|
|
||||||
// If they favorited us and provided their Nostr key, ensure it's stored
|
|
||||||
if isFavorite && extractedNostrPubkey != nil {
|
|
||||||
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session)
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
peerNostrPublicKey: extractedNostrPubkey,
|
|
||||||
peerNickname: senderNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification
|
|
||||||
NotificationService.shared.sendLocalNotification(
|
|
||||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
|
||||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
|
||||||
identifier: "fav-\(UUID().uuidString)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
|
||||||
// Find peer Nostr key
|
|
||||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
|
||||||
relationship.peerNostrPublicKey != nil else {
|
|
||||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let peerID = PeerID(hexData: noisePublicKey)
|
|
||||||
|
|
||||||
// Route via message router
|
|
||||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
|
||||||
|
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
|
||||||
// Look up current visible geohash participants for an exact displayName match
|
|
||||||
for p in visibleGeohashPeople() {
|
|
||||||
if p.displayName == name {
|
|
||||||
return p.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also check nickname cache directly
|
|
||||||
for (pub, nick) in geoNicknames {
|
|
||||||
if nick == name { return pub }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func startGeohashDM(withPubkeyHex hex: String) {
|
|
||||||
let convKey = PeerID(nostr_: hex)
|
|
||||||
nostrKeyMapping[convKey] = hex
|
|
||||||
startPrivateChat(with: convKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
|
||||||
return nostrKeyMapping[senderID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
|
||||||
guard let full = nostrKeyMapping[convKey] else {
|
|
||||||
return convKey.bare
|
|
||||||
}
|
|
||||||
return displayNameForNostrPubkey(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModel+Tor.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Tor lifecycle handling for ChatViewModel
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
extension ChatViewModel {
|
|
||||||
|
|
||||||
// MARK: - Tor notifications
|
|
||||||
|
|
||||||
@objc func handleTorWillStart() {
|
|
||||||
Task { @MainActor in
|
|
||||||
if !self.torStatusAnnounced && TorManager.shared.torEnforced {
|
|
||||||
self.torStatusAnnounced = true
|
|
||||||
// Post only in geohash channels (queue if not active)
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTorWillRestart() {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.torRestartPending = true
|
|
||||||
// Post only in geohash channels (queue if not active)
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTorDidBecomeReady() {
|
|
||||||
Task { @MainActor in
|
|
||||||
// Only announce "restarted" if we actually restarted this session
|
|
||||||
if self.torRestartPending {
|
|
||||||
// Post only in geohash channels (queue if not active)
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
|
|
||||||
)
|
|
||||||
self.torRestartPending = false
|
|
||||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
|
||||||
// Initial start completed
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.started", comment: "System message when Tor has started")
|
|
||||||
)
|
|
||||||
self.torInitialReadyAnnounced = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTorPreferenceChanged(_ notification: Notification) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.torStatusAnnounced = false
|
|
||||||
self.torInitialReadyAnnounced = false
|
|
||||||
self.torRestartPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# ChatViewModel Extensions
|
|
||||||
|
|
||||||
This directory contains extensions to `ChatViewModel` to modularize its functionality.
|
|
||||||
|
|
||||||
- `ChatViewModel+Tor.swift`: Handles Tor lifecycle events and notifications.
|
|
||||||
- `ChatViewModel+PrivateChat.swift`: Manages private chat logic, media transfers (images, voice notes), and file handling.
|
|
||||||
- `ChatViewModel+Nostr.swift`: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management.
|
|
||||||
|
|
||||||
The main `ChatViewModel.swift` retains core state, initialization, and coordination logic.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
//
|
|
||||||
// GeoChannelCoordinator.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Centralizes Combine wiring for location channel selection and sampling.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class GeoChannelCoordinator {
|
|
||||||
private let locationManager: LocationChannelManager
|
|
||||||
private let bookmarksStore: GeohashBookmarksStore
|
|
||||||
private let torManager: TorManager
|
|
||||||
|
|
||||||
private let onChannelSwitch: (ChannelID) -> Void
|
|
||||||
private let beginSampling: ([String]) -> Void
|
|
||||||
private let endSampling: () -> Void
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
private var regionalGeohashes: [String] = []
|
|
||||||
private var bookmarkedGeohashes: [String] = []
|
|
||||||
|
|
||||||
init(
|
|
||||||
locationManager: LocationChannelManager? = nil,
|
|
||||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
|
||||||
torManager: TorManager? = nil,
|
|
||||||
onChannelSwitch: @escaping (ChannelID) -> Void,
|
|
||||||
beginSampling: @escaping ([String]) -> Void,
|
|
||||||
endSampling: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
|
||||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
|
||||||
self.torManager = torManager ?? Self.defaultTorManager()
|
|
||||||
self.onChannelSwitch = onChannelSwitch
|
|
||||||
self.beginSampling = beginSampling
|
|
||||||
self.endSampling = endSampling
|
|
||||||
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
|
|
||||||
bookmarkedGeohashes = bookmarksStore.bookmarks
|
|
||||||
|
|
||||||
locationManager.$selectedChannel
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] channel in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.onChannelSwitch(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
locationManager.$availableChannels
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] channels in
|
|
||||||
guard let self else { return }
|
|
||||||
self.regionalGeohashes = channels.map { $0.geohash }
|
|
||||||
self.updateSampling()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
bookmarksStore.$bookmarks
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] bookmarks in
|
|
||||||
guard let self else { return }
|
|
||||||
self.bookmarkedGeohashes = bookmarks
|
|
||||||
self.updateSampling()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
locationManager.$permissionState
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] state in
|
|
||||||
guard let self, state == .authorized else { return }
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.locationManager.refreshChannels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
self.onChannelSwitch(self.locationManager.selectedChannel)
|
|
||||||
}
|
|
||||||
updateSampling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSampling() {
|
|
||||||
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
|
||||||
Task { @MainActor in
|
|
||||||
guard !union.isEmpty else {
|
|
||||||
endSampling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if torManager.isForeground() {
|
|
||||||
beginSampling(union)
|
|
||||||
} else {
|
|
||||||
endSampling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshSampling() {
|
|
||||||
updateSampling()
|
|
||||||
}
|
|
||||||
private static func defaultLocationManager() -> LocationChannelManager {
|
|
||||||
LocationChannelManager.shared
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func defaultTorManager() -> TorManager {
|
|
||||||
TorManager.shared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageRateLimiter.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles per-sender and per-content token buckets for public message intake.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct MessageRateLimiter {
|
|
||||||
private struct TokenBucket {
|
|
||||||
var capacity: Double
|
|
||||||
var tokens: Double
|
|
||||||
var refillPerSec: Double
|
|
||||||
var lastRefill: Date
|
|
||||||
|
|
||||||
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
|
||||||
let dt = now.timeIntervalSince(lastRefill)
|
|
||||||
if dt > 0 {
|
|
||||||
tokens = min(capacity, tokens + dt * refillPerSec)
|
|
||||||
lastRefill = now
|
|
||||||
}
|
|
||||||
if tokens >= cost {
|
|
||||||
tokens -= cost
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
|
||||||
private var contentBuckets: [String: TokenBucket] = [:]
|
|
||||||
|
|
||||||
private let senderCapacity: Double
|
|
||||||
private let senderRefill: Double
|
|
||||||
private let contentCapacity: Double
|
|
||||||
private let contentRefill: Double
|
|
||||||
|
|
||||||
init(
|
|
||||||
senderCapacity: Double,
|
|
||||||
senderRefillPerSec: Double,
|
|
||||||
contentCapacity: Double,
|
|
||||||
contentRefillPerSec: Double
|
|
||||||
) {
|
|
||||||
self.senderCapacity = senderCapacity
|
|
||||||
self.senderRefill = senderRefillPerSec
|
|
||||||
self.contentCapacity = contentCapacity
|
|
||||||
self.contentRefill = contentRefillPerSec
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
|
||||||
capacity: senderCapacity,
|
|
||||||
tokens: senderCapacity,
|
|
||||||
refillPerSec: senderRefill,
|
|
||||||
lastRefill: now
|
|
||||||
)
|
|
||||||
let senderAllowed = senderBucket.allow(now: now)
|
|
||||||
senderBuckets[senderKey] = senderBucket
|
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
|
||||||
capacity: contentCapacity,
|
|
||||||
tokens: contentCapacity,
|
|
||||||
refillPerSec: contentRefill,
|
|
||||||
lastRefill: now
|
|
||||||
)
|
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
|
||||||
contentBuckets[contentKey] = contentBucket
|
|
||||||
|
|
||||||
return senderAllowed && contentAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func reset() {
|
|
||||||
senderBuckets.removeAll()
|
|
||||||
contentBuckets.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
//
|
|
||||||
// MinimalDistancePalette.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Lightweight palette generator that keeps peer colors evenly spaced.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class MinimalDistancePalette {
|
|
||||||
struct Config {
|
|
||||||
let slotCount: Int
|
|
||||||
let avoidCenterHue: Double
|
|
||||||
let avoidHueDelta: Double
|
|
||||||
let saturationLight: Double
|
|
||||||
let saturationDark: Double
|
|
||||||
let baseBrightnessLight: Double
|
|
||||||
let baseBrightnessDark: Double
|
|
||||||
let ringBrightnessDeltaLight: Double
|
|
||||||
let ringBrightnessDeltaDark: Double
|
|
||||||
let preferredBiasWeight: Double
|
|
||||||
let goldenStep: Int
|
|
||||||
|
|
||||||
init(
|
|
||||||
slotCount: Int,
|
|
||||||
avoidCenterHue: Double,
|
|
||||||
avoidHueDelta: Double,
|
|
||||||
saturationLight: Double,
|
|
||||||
saturationDark: Double,
|
|
||||||
baseBrightnessLight: Double,
|
|
||||||
baseBrightnessDark: Double,
|
|
||||||
ringBrightnessDeltaLight: Double,
|
|
||||||
ringBrightnessDeltaDark: Double,
|
|
||||||
preferredBiasWeight: Double = 0.05,
|
|
||||||
goldenStep: Int = 7
|
|
||||||
) {
|
|
||||||
self.slotCount = slotCount
|
|
||||||
self.avoidCenterHue = avoidCenterHue
|
|
||||||
self.avoidHueDelta = avoidHueDelta
|
|
||||||
self.saturationLight = saturationLight
|
|
||||||
self.saturationDark = saturationDark
|
|
||||||
self.baseBrightnessLight = baseBrightnessLight
|
|
||||||
self.baseBrightnessDark = baseBrightnessDark
|
|
||||||
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
|
|
||||||
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
|
|
||||||
self.preferredBiasWeight = preferredBiasWeight
|
|
||||||
self.goldenStep = goldenStep
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Entry {
|
|
||||||
let slot: Int
|
|
||||||
let ring: Int
|
|
||||||
let hue: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
private let config: Config
|
|
||||||
private var currentSeeds: [String: String] = [:]
|
|
||||||
private var entries: [String: Entry] = [:]
|
|
||||||
private var previousEntries: [String: Entry] = [:]
|
|
||||||
|
|
||||||
init(config: Config) {
|
|
||||||
self.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func ensurePalette(for seeds: [String: String]) {
|
|
||||||
guard seeds != currentSeeds || entries.count != seeds.count else { return }
|
|
||||||
previousEntries = entries
|
|
||||||
currentSeeds = seeds
|
|
||||||
rebuildEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func color(for identifier: String, isDark: Bool) -> Color? {
|
|
||||||
guard let entry = entries[identifier] else { return nil }
|
|
||||||
let saturation = isDark ? config.saturationDark : config.saturationLight
|
|
||||||
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
|
|
||||||
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
|
|
||||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
|
|
||||||
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func reset() {
|
|
||||||
currentSeeds.removeAll()
|
|
||||||
entries.removeAll()
|
|
||||||
previousEntries.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func rebuildEntries() {
|
|
||||||
guard !currentSeeds.isEmpty else {
|
|
||||||
entries.removeAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let slotCount = max(8, config.slotCount)
|
|
||||||
var slots: [Double] = []
|
|
||||||
for idx in 0..<slotCount {
|
|
||||||
let hue = Double(idx) / Double(slotCount)
|
|
||||||
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slots.append(hue)
|
|
||||||
}
|
|
||||||
if slots.isEmpty {
|
|
||||||
for idx in 0..<slotCount {
|
|
||||||
slots.append(Double(idx) / Double(slotCount))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func circularDistance(_ a: Double, _ b: Double) -> Double {
|
|
||||||
let diff = abs(a - b)
|
|
||||||
return diff > 0.5 ? 1.0 - diff : diff
|
|
||||||
}
|
|
||||||
|
|
||||||
let peerIDs = currentSeeds.keys.sorted()
|
|
||||||
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
|
|
||||||
let seed = currentSeeds[id] ?? id
|
|
||||||
let hash = seed.djb2()
|
|
||||||
let index = Int(hash % UInt64(slots.count))
|
|
||||||
return (id, index)
|
|
||||||
})
|
|
||||||
|
|
||||||
var mapping: [String: Entry] = [:]
|
|
||||||
var usedSlots = Set<Int>()
|
|
||||||
var usedHues: [Double] = []
|
|
||||||
|
|
||||||
let prior = entries.isEmpty ? previousEntries : entries
|
|
||||||
for (id, entry) in prior {
|
|
||||||
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
|
|
||||||
let hue = slots[entry.slot]
|
|
||||||
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
|
|
||||||
usedSlots.insert(entry.slot)
|
|
||||||
usedHues.append(hue)
|
|
||||||
}
|
|
||||||
|
|
||||||
let unassigned = peerIDs.filter { mapping[$0] == nil }
|
|
||||||
for id in unassigned {
|
|
||||||
let preferred = preferredIndex[id] ?? 0
|
|
||||||
if !usedSlots.contains(preferred), preferred < slots.count {
|
|
||||||
let hue = slots[preferred]
|
|
||||||
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
|
|
||||||
usedSlots.insert(preferred)
|
|
||||||
usedHues.append(hue)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestSlot: Int?
|
|
||||||
var bestScore = -Double.infinity
|
|
||||||
for slot in 0..<slots.count where !usedSlots.contains(slot) {
|
|
||||||
let hue = slots[slot]
|
|
||||||
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
|
|
||||||
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
|
||||||
let score = minDistance + config.preferredBiasWeight * bias
|
|
||||||
if score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestSlot = slot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let slot = bestSlot {
|
|
||||||
let hue = slots[slot]
|
|
||||||
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
|
|
||||||
usedSlots.insert(slot)
|
|
||||||
usedHues.append(hue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let remaining = peerIDs.filter { mapping[$0] == nil }
|
|
||||||
if !remaining.isEmpty {
|
|
||||||
for (index, id) in remaining.enumerated() {
|
|
||||||
let preferred = preferredIndex[id] ?? 0
|
|
||||||
let slot = (preferred + index * config.goldenStep) % slots.count
|
|
||||||
let hue = slots[slot]
|
|
||||||
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = mapping
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MinimalDistancePalette.Config {
|
|
||||||
static let mesh = MinimalDistancePalette.Config(
|
|
||||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
|
||||||
avoidCenterHue: 30.0 / 360.0,
|
|
||||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
|
||||||
saturationLight: 0.70,
|
|
||||||
saturationDark: 0.80,
|
|
||||||
baseBrightnessLight: 0.45,
|
|
||||||
baseBrightnessDark: 0.75,
|
|
||||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
|
||||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
|
||||||
)
|
|
||||||
|
|
||||||
static let nostr = MinimalDistancePalette.Config(
|
|
||||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
|
||||||
avoidCenterHue: 30.0 / 360.0,
|
|
||||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
|
||||||
saturationLight: 0.70,
|
|
||||||
saturationDark: 0.80,
|
|
||||||
baseBrightnessLight: 0.45,
|
|
||||||
baseBrightnessDark: 0.75,
|
|
||||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
|
||||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
//
|
|
||||||
// PublicMessagePipeline.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
protocol PublicMessagePipelineDelegate: AnyObject {
|
|
||||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
|
|
||||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
|
|
||||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
|
|
||||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class PublicMessagePipeline {
|
|
||||||
weak var delegate: PublicMessagePipelineDelegate?
|
|
||||||
|
|
||||||
private var buffer: [BitchatMessage] = []
|
|
||||||
private var timer: Timer?
|
|
||||||
private let baseFlushInterval: TimeInterval
|
|
||||||
private var dynamicFlushInterval: TimeInterval
|
|
||||||
private var recentBatchSizes: [Int] = []
|
|
||||||
private let maxRecentBatchSamples: Int
|
|
||||||
private let dedupWindow: TimeInterval
|
|
||||||
private var activeChannel: ChannelID = .mesh
|
|
||||||
|
|
||||||
init(
|
|
||||||
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
|
|
||||||
maxRecentBatchSamples: Int = 10,
|
|
||||||
dedupWindow: TimeInterval = 1.0
|
|
||||||
) {
|
|
||||||
self.baseFlushInterval = baseFlushInterval
|
|
||||||
self.dynamicFlushInterval = baseFlushInterval
|
|
||||||
self.maxRecentBatchSamples = maxRecentBatchSamples
|
|
||||||
self.dedupWindow = dedupWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
timer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateActiveChannel(_ channel: ChannelID) {
|
|
||||||
activeChannel = channel
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueue(_ message: BitchatMessage) {
|
|
||||||
buffer.append(message)
|
|
||||||
scheduleFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func flushIfNeeded() {
|
|
||||||
flushBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension PublicMessagePipeline {
|
|
||||||
func scheduleFlush() {
|
|
||||||
guard timer == nil else { return }
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.flushBuffer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func flushBuffer() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
guard !buffer.isEmpty else { return }
|
|
||||||
guard let delegate = delegate else {
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: true)
|
|
||||||
|
|
||||||
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
|
|
||||||
var pending: [(message: BitchatMessage, contentKey: String)] = []
|
|
||||||
var batchContentLatest: [String: Date] = [:]
|
|
||||||
|
|
||||||
for message in buffer {
|
|
||||||
if existingIDs.contains(message.id) { continue }
|
|
||||||
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
|
|
||||||
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
|
|
||||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if let ts = batchContentLatest[contentKey],
|
|
||||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingIDs.insert(message.id)
|
|
||||||
pending.append((message, contentKey))
|
|
||||||
batchContentLatest[contentKey] = message.timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.removeAll(keepingCapacity: true)
|
|
||||||
guard !pending.isEmpty else {
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
|
||||||
if !buffer.isEmpty { scheduleFlush() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pending.sort { $0.message.timestamp < $1.message.timestamp }
|
|
||||||
|
|
||||||
var messages = delegate.pipelineCurrentMessages(self)
|
|
||||||
let threshold = lateInsertThreshold(for: activeChannel)
|
|
||||||
let lastTimestamp = messages.last?.timestamp ?? .distantPast
|
|
||||||
|
|
||||||
for item in pending {
|
|
||||||
let message = item.message
|
|
||||||
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
|
|
||||||
let index = insertionIndex(for: message.timestamp, in: messages)
|
|
||||||
if index >= messages.count {
|
|
||||||
messages.append(message)
|
|
||||||
} else {
|
|
||||||
messages.insert(message, at: index)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
messages.append(message)
|
|
||||||
}
|
|
||||||
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate.pipeline(self, setMessages: messages)
|
|
||||||
delegate.pipelineTrimMessages(self)
|
|
||||||
|
|
||||||
updateFlushInterval(withBatchSize: pending.count)
|
|
||||||
|
|
||||||
for item in pending {
|
|
||||||
delegate.pipelinePrewarmMessage(self, message: item.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
|
||||||
|
|
||||||
if !buffer.isEmpty {
|
|
||||||
scheduleFlush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateFlushInterval(withBatchSize size: Int) {
|
|
||||||
recentBatchSizes.append(size)
|
|
||||||
if recentBatchSizes.count > maxRecentBatchSamples {
|
|
||||||
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
|
|
||||||
}
|
|
||||||
let avg = recentBatchSizes.isEmpty
|
|
||||||
? 0.0
|
|
||||||
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
|
||||||
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
return TransportConfig.uiLateInsertThreshold
|
|
||||||
case .location:
|
|
||||||
return TransportConfig.uiLateInsertThresholdGeo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
|
|
||||||
var low = 0
|
|
||||||
var high = messages.count
|
|
||||||
while low < high {
|
|
||||||
let mid = (low + high) / 2
|
|
||||||
if messages[mid].timestamp < timestamp {
|
|
||||||
low = mid + 1
|
|
||||||
} else {
|
|
||||||
high = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return low
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
//
|
|
||||||
// PublicTimelineStore.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct PublicTimelineStore {
|
|
||||||
private var meshTimeline: [BitchatMessage] = []
|
|
||||||
private var geohashTimelines: [String: [BitchatMessage]] = [:]
|
|
||||||
private var pendingGeohashSystemMessages: [String] = []
|
|
||||||
|
|
||||||
private let meshCap: Int
|
|
||||||
private let geohashCap: Int
|
|
||||||
|
|
||||||
init(meshCap: Int, geohashCap: Int) {
|
|
||||||
self.meshCap = meshCap
|
|
||||||
self.geohashCap = geohashCap
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
|
|
||||||
meshTimeline.append(message)
|
|
||||||
trimMeshTimelineIfNeeded()
|
|
||||||
case .location(let channel):
|
|
||||||
append(message, toGeohash: channel.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
guard !timeline.contains(where: { $0.id == message.id }) else { return }
|
|
||||||
timeline.append(message)
|
|
||||||
trimGeohashTimelineIfNeeded(&timeline)
|
|
||||||
geohashTimelines[geohash] = timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append message if absent, returning true when stored.
|
|
||||||
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
|
|
||||||
timeline.append(message)
|
|
||||||
trimGeohashTimelineIfNeeded(&timeline)
|
|
||||||
geohashTimelines[geohash] = timeline
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
return meshTimeline
|
|
||||||
case .location(let channel):
|
|
||||||
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
|
|
||||||
geohashTimelines[channel.geohash] = cleaned
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func clear(channel: ChannelID) {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
meshTimeline.removeAll()
|
|
||||||
case .location(let channel):
|
|
||||||
geohashTimelines[channel.geohash] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func removeMessage(withID id: String) -> BitchatMessage? {
|
|
||||||
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
|
||||||
return meshTimeline.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in Array(geohashTimelines.keys) {
|
|
||||||
var timeline = geohashTimelines[key] ?? []
|
|
||||||
if let index = timeline.firstIndex(where: { $0.id == id }) {
|
|
||||||
let removed = timeline.remove(at: index)
|
|
||||||
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
timeline.removeAll(where: predicate)
|
|
||||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
transform(&timeline)
|
|
||||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func queueGeohashSystemMessage(_ content: String) {
|
|
||||||
pendingGeohashSystemMessages.append(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func drainPendingGeohashSystemMessages() -> [String] {
|
|
||||||
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
|
|
||||||
return pendingGeohashSystemMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
func geohashKeys() -> [String] {
|
|
||||||
Array(geohashTimelines.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func trimMeshTimelineIfNeeded() {
|
|
||||||
guard meshTimeline.count > meshCap else { return }
|
|
||||||
meshTimeline = Array(meshTimeline.suffix(meshCap))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
|
|
||||||
guard timeline.count > geohashCap else { return }
|
|
||||||
timeline = Array(timeline.suffix(geohashCap))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,6 +86,10 @@ struct AppInfoView: View {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Warning {
|
||||||
|
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||||
|
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -188,6 +192,24 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
FeatureRow(info: Strings.Privacy.panic)
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warning
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
SectionHeader(Strings.Warning.title)
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
|
||||||
|
Text(Strings.Warning.message)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
|
||||||
|
.padding(.top)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// CommandSuggestionsView.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Created by Islam on 29/10/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
|
||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
|
||||||
|
|
||||||
@Binding var messageText: String
|
|
||||||
|
|
||||||
let textColor: Color
|
|
||||||
let backgroundColor: Color
|
|
||||||
let secondaryTextColor: Color
|
|
||||||
|
|
||||||
private var filteredCommands: [CommandInfo] {
|
|
||||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
|
||||||
let isGeoPublic = locationManager.selectedChannel.isLocation
|
|
||||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
|
||||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
|
||||||
command.alias.starts(with: messageText.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
ForEach(filteredCommands) { command in
|
|
||||||
Button {
|
|
||||||
messageText = command.alias + " "
|
|
||||||
} label: {
|
|
||||||
buttonRow(for: command)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buttonRow(for command: CommandInfo) -> some View {
|
|
||||||
HStack {
|
|
||||||
Text(command.alias)
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
if let placeholder = command.placeholder {
|
|
||||||
Text(placeholder)
|
|
||||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(command.description)
|
|
||||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17, macOS 14, *)
|
|
||||||
#Preview {
|
|
||||||
@Previewable @State var messageText: String = "/"
|
|
||||||
let keychain = KeychainManager()
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: NostrIdentityBridge(),
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
|
|
||||||
CommandSuggestionsView(
|
|
||||||
messageText: $messageText,
|
|
||||||
textColor: .green,
|
|
||||||
backgroundColor: .primary,
|
|
||||||
secondaryTextColor: .secondary
|
|
||||||
)
|
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
|
||||||
@@ -16,21 +16,9 @@ struct PaymentChipView: View {
|
|||||||
case cashu(String)
|
case cashu(String)
|
||||||
case lightning(String)
|
case lightning(String)
|
||||||
|
|
||||||
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
|
|
||||||
private static func cashuURL(from link: String) -> URL? {
|
|
||||||
if let url = URL(string: link), url.scheme != nil {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
|
|
||||||
return URL(string: "cashu:\(enc)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var url: URL? {
|
var url: URL? {
|
||||||
switch self {
|
switch self {
|
||||||
case .cashu(let link):
|
case .cashu(let link), .lightning(let link):
|
||||||
return Self.cashuURL(from: link)
|
|
||||||
case .lightning(let link):
|
|
||||||
return URL(string: link)
|
return URL(string: link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+282
-167
@@ -40,11 +40,14 @@ struct ContentView: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
@State private var showPeerList = false
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var showAppInfo = false
|
@State private var showAppInfo = false
|
||||||
|
@State private var showCommandSuggestions = false
|
||||||
|
@State private var commandSuggestions: [String] = []
|
||||||
@State private var showMessageActions = false
|
@State private var showMessageActions = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: PeerID?
|
@State private var selectedMessageSenderID: String?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@@ -56,6 +59,7 @@ struct ContentView: View {
|
|||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
|
@State private var sheetNotesCount: Int = 0
|
||||||
@State private var imagePreviewURL: URL? = nil
|
@State private var imagePreviewURL: URL? = nil
|
||||||
@State private var recordingAlertMessage: String = ""
|
@State private var recordingAlertMessage: String = ""
|
||||||
@State private var showRecordingAlert = false
|
@State private var showRecordingAlert = false
|
||||||
@@ -76,7 +80,7 @@ struct ContentView: View {
|
|||||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
@State private var windowCountPrivate: [String: Int] = [:]
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -120,7 +124,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
|
|
||||||
private struct PrivateHeaderContext {
|
private struct PrivateHeaderContext {
|
||||||
let headerPeerID: PeerID
|
let headerPeerID: String
|
||||||
let peer: BitchatPeer?
|
let peer: BitchatPeer?
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let isNostrAvailable: Bool
|
let isNostrAvailable: Bool
|
||||||
@@ -184,10 +188,13 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
peopleSheetView
|
peopleSheetView
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAppInfo) {
|
.sheet(isPresented: $showAppInfo) {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
.environmentObject(viewModel)
|
|
||||||
.onAppear { viewModel.isAppInfoPresented = true }
|
.onAppear { viewModel.isAppInfoPresented = true }
|
||||||
.onDisappear { viewModel.isAppInfoPresented = false }
|
.onDisappear { viewModel.isAppInfoPresented = false }
|
||||||
}
|
}
|
||||||
@@ -196,20 +203,11 @@ struct ContentView: View {
|
|||||||
set: { _ in viewModel.showingFingerprintFor = nil }
|
set: { _ in viewModel.showingFingerprintFor = nil }
|
||||||
)) {
|
)) {
|
||||||
if let peerID = viewModel.showingFingerprintFor {
|
if let peerID = viewModel.showingFingerprintFor {
|
||||||
FingerprintView(viewModel: viewModel, peerID: peerID)
|
FingerprintView(viewModel: viewModel, peerID: peerID.id)
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Only present image picker from main view when NOT in a sheet
|
.sheet(isPresented: $showImagePicker) {
|
||||||
.fullScreenCover(isPresented: Binding(
|
|
||||||
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
|
||||||
set: { newValue in
|
|
||||||
if !newValue {
|
|
||||||
showImagePicker = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)) {
|
|
||||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
showImagePicker = false
|
showImagePicker = false
|
||||||
if let image = image {
|
if let image = image {
|
||||||
@@ -225,20 +223,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel)
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// Only present Mac image picker from main view when NOT in a sheet
|
.sheet(isPresented: $showMacImagePicker) {
|
||||||
.sheet(isPresented: Binding(
|
|
||||||
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
|
||||||
set: { newValue in
|
|
||||||
if !newValue {
|
|
||||||
showMacImagePicker = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)) {
|
|
||||||
MacImagePickerView { url in
|
MacImagePickerView { url in
|
||||||
showMacImagePicker = false
|
showMacImagePicker = false
|
||||||
if let url = url {
|
if let url = url {
|
||||||
@@ -254,7 +245,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
@@ -263,7 +253,6 @@ struct ContentView: View {
|
|||||||
)) {
|
)) {
|
||||||
if let url = imagePreviewURL {
|
if let url = imagePreviewURL {
|
||||||
ImagePreviewView(url: url)
|
ImagePreviewView(url: url)
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||||
@@ -286,12 +275,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.direct_message") {
|
Button("content.actions.direct_message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
if peerID.isGeoChat {
|
if peerID.hasPrefix("nostr:") {
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -313,8 +302,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.block", role: .destructive) {
|
Button("content.actions.block", role: .destructive) {
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
|
||||||
let sender = selectedMessageSender {
|
let sender = selectedMessageSender {
|
||||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||||
} else if let sender = selectedMessageSender {
|
} else if let sender = selectedMessageSender {
|
||||||
@@ -345,9 +334,9 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Message List View
|
// MARK: - Message List View
|
||||||
|
|
||||||
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
||||||
let messages: [BitchatMessage] = {
|
let messages: [BitchatMessage] = {
|
||||||
if let peerID = privatePeer {
|
if let peerID = PeerID(str: privatePeer) {
|
||||||
return viewModel.getPrivateChatMessages(for: peerID)
|
return viewModel.getPrivateChatMessages(for: peerID)
|
||||||
}
|
}
|
||||||
return viewModel.messages
|
return viewModel.messages
|
||||||
@@ -468,17 +457,17 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Schedule a delayed scroll
|
// Schedule a delayed scroll
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { [weak viewModel] _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||||
Task { @MainActor in
|
lastScrollTime = Date()
|
||||||
lastScrollTime = Date()
|
let contextKey: String = {
|
||||||
let contextKey: String = {
|
switch locationManager.selectedChannel {
|
||||||
switch locationManager.selectedChannel {
|
case .mesh: return "mesh"
|
||||||
case .mesh: return "mesh"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
}
|
||||||
}
|
}()
|
||||||
}()
|
let count = windowCountPublic
|
||||||
let count = windowCountPublic
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
let target = viewModel?.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
DispatchQueue.main.async {
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,7 +476,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.privateChats) { _ in
|
.onChange(of: viewModel.privateChats) { _ in
|
||||||
if let peerID = privatePeer,
|
if let peerID = privatePeer,
|
||||||
let messages = viewModel.privateChats[peerID],
|
let messages = viewModel.privateChats[PeerID(str: peerID)],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
// If the newest private message is from me, always scroll
|
// If the newest private message is from me, always scroll
|
||||||
let lastMsg = messages.last!
|
let lastMsg = messages.last!
|
||||||
@@ -542,7 +531,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = privatePeer {
|
if let peerID = PeerID(str: privatePeer) {
|
||||||
// Try multiple times to ensure read receipts are sent
|
// Try multiple times to ensure read receipts are sent
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
@@ -606,12 +595,77 @@ struct ContentView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandSuggestionsView(
|
// Command suggestions
|
||||||
messageText: $messageText,
|
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||||
textColor: textColor,
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
backgroundColor: backgroundColor,
|
// Define commands with aliases and syntax
|
||||||
secondaryTextColor: secondaryTextColor
|
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||||
)
|
(["/block"], "[nickname]", "block or list blocked peers"),
|
||||||
|
(["/clear"], nil, "clear chat messages"),
|
||||||
|
(["/hug"], "<nickname>", "send someone a warm hug"),
|
||||||
|
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
||||||
|
(["/slap"], "<nickname>", "slap someone with a trout"),
|
||||||
|
(["/unblock"], "<nickname>", "unblock a peer"),
|
||||||
|
(["/w"], nil, "see who's online")
|
||||||
|
]
|
||||||
|
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
|
||||||
|
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||||
|
(["/fav"], "<nickname>", "add to favorites"),
|
||||||
|
(["/unfav"], "<nickname>", "remove from favorites")
|
||||||
|
]
|
||||||
|
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
|
||||||
|
|
||||||
|
// Build the display
|
||||||
|
let allCommands = commandInfo
|
||||||
|
|
||||||
|
// Show matching commands
|
||||||
|
ForEach(commandSuggestions, id: \.self) { command in
|
||||||
|
// Find the command info for this suggestion
|
||||||
|
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
|
||||||
|
Button(action: {
|
||||||
|
// Replace current text with selected command
|
||||||
|
messageText = command + " "
|
||||||
|
showCommandSuggestions = false
|
||||||
|
commandSuggestions = []
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
// Show all aliases together
|
||||||
|
Text(info.commands.joined(separator: ", "))
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
// Show syntax if any
|
||||||
|
if let syntax = info.syntax {
|
||||||
|
Text(syntax)
|
||||||
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Show description
|
||||||
|
Text(info.description)
|
||||||
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
|
||||||
// Recording indicator
|
// Recording indicator
|
||||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
if isPreparingVoiceNote || isRecordingVoiceNote {
|
||||||
@@ -645,12 +699,67 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.onChange(of: messageText) { newValue in
|
.onChange(of: messageText) { newValue in
|
||||||
|
// Cancel previous debounce timer
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak viewModel] _ in
|
|
||||||
|
// 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
|
let cursorPosition = newValue.count
|
||||||
Task { @MainActor in
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||||
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 = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
var commandDescriptions = [
|
||||||
|
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
|
||||||
|
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
|
||||||
|
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
|
||||||
|
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
|
||||||
|
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
|
||||||
|
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
|
||||||
|
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
|
||||||
|
]
|
||||||
|
// Only show favorites commands when not in geohash context
|
||||||
|
if !(isGeoPublic || isGeoDM) {
|
||||||
|
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
|
||||||
|
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,7 +784,7 @@ struct ContentView: View {
|
|||||||
case "user":
|
case "user":
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
selectedMessageSenderID = peerID
|
selectedMessageSenderID = peerID.id
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
@@ -723,10 +832,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
private func scrollToBottom(on proxy: ScrollViewProxy,
|
||||||
privatePeer: PeerID?,
|
privatePeer: String?,
|
||||||
isAtBottom: Binding<Bool>) {
|
isAtBottom: Binding<Bool>) {
|
||||||
let targetID: String? = {
|
let targetID: String? = {
|
||||||
if let peer = privatePeer,
|
if let peer = PeerID(str: privatePeer),
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
@@ -752,7 +861,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
let secondTarget: String? = {
|
let secondTarget: String? = {
|
||||||
if let peer = privatePeer,
|
if let peer = PeerID(str: privatePeer),
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
@@ -803,55 +912,6 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
// Present image picker from sheet context when IN a sheet (parent-child pattern)
|
|
||||||
#if os(iOS)
|
|
||||||
.fullScreenCover(isPresented: Binding(
|
|
||||||
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
|
||||||
set: { newValue in
|
|
||||||
if !newValue {
|
|
||||||
showImagePicker = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)) {
|
|
||||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
|
||||||
showImagePicker = false
|
|
||||||
if let image = image {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: processedURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.environmentObject(viewModel)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
|
||||||
.sheet(isPresented: $showMacImagePicker) {
|
|
||||||
MacImagePickerView { url in
|
|
||||||
showMacImagePicker = false
|
|
||||||
if let url = url {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: processedURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - People Sheet Views
|
// MARK: - People Sheet Views
|
||||||
@@ -938,14 +998,14 @@ struct ContentView: View {
|
|||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
},
|
},
|
||||||
onToggleFavorite: { peerID in
|
onToggleFavorite: { peerID in
|
||||||
viewModel.toggleFavorite(peerID: peerID)
|
viewModel.toggleFavorite(peerID: PeerID(str: peerID))
|
||||||
},
|
},
|
||||||
onShowFingerprint: { peerID in
|
onShowFingerprint: { peerID in
|
||||||
viewModel.showFingerprint(for: peerID)
|
viewModel.showFingerprint(for: PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -960,7 +1020,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var privateChatSheetView: some View {
|
private var privateChatSheetView: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
|
||||||
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -984,11 +1044,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
||||||
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
let peerID = PeerID(str: headerContext.headerPeerID)
|
||||||
|
let isFavorite = viewModel.isFavorite(peerID: peerID)
|
||||||
|
|
||||||
if !privatePeerID.isGeoDM {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
viewModel.toggleFavorite(peerID: peerID)
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: isFavorite ? "star.fill" : "star")
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
@@ -1027,7 +1088,7 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
Divider()
|
Divider()
|
||||||
@@ -1049,9 +1110,9 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
|
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.showFingerprint(for: context.headerPeerID)
|
viewModel.showFingerprint(for: PeerID(str: context.headerPeerID))
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let connectionState = context.peer?.connectionState {
|
if let connectionState = context.peer?.connectionState {
|
||||||
@@ -1074,7 +1135,7 @@ struct ContentView: View {
|
|||||||
case .offline:
|
case .offline:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
|
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -1084,7 +1145,7 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||||
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(PeerID(str: context.headerPeerID)) {
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -1095,9 +1156,14 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
if !privatePeerID.isGeoDM {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
let statusPeerID: String = {
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
return short.id
|
||||||
|
}
|
||||||
|
return context.headerPeerID
|
||||||
|
}()
|
||||||
|
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
@@ -1129,27 +1195,33 @@ struct ContentView: View {
|
|||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
|
||||||
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
let headerPeerID: String = {
|
||||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
return short.id
|
||||||
|
}
|
||||||
|
return privatePeerID
|
||||||
|
}()
|
||||||
|
|
||||||
|
let peer = viewModel.getPeer(byID: PeerID(str: headerPeerID))
|
||||||
|
|
||||||
let displayName: String = {
|
let displayName: String = {
|
||||||
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
|
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
|
||||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
let disp = viewModel.geohashDisplayName(for: PeerID(str: privatePeerID))
|
||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
if let name = peer?.displayName { return name }
|
if let name = peer?.displayName { return name }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
if headerPeerID.id.count == 16 {
|
if headerPeerID.count == 16 {
|
||||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
||||||
if let id = candidates.first,
|
if let id = candidates.first,
|
||||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
} else if let keyData = headerPeerID.noiseKey {
|
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
||||||
let fp = keyData.sha256Fingerprint()
|
let fp = keyData.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
@@ -1161,7 +1233,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
if let noiseKey = Data(hexString: headerPeerID),
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
favoriteStatus.isMutual {
|
favoriteStatus.isMutual {
|
||||||
return true
|
return true
|
||||||
@@ -1179,6 +1251,19 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split a name into base and a '#abcd' suffix if present
|
||||||
|
private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) {
|
||||||
|
guard name.count >= 5 else { return (name, "") }
|
||||||
|
let suffix = String(name.suffix(5))
|
||||||
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||||
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||||
|
}) {
|
||||||
|
let base = String(name.dropLast(5))
|
||||||
|
return (base, suffix)
|
||||||
|
}
|
||||||
|
return (name, "")
|
||||||
|
}
|
||||||
|
|
||||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -1293,8 +1378,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Bookmark toggle (geochats): to the left of #geohash
|
// Bookmark toggle (geochats): to the left of #geohash
|
||||||
if case .location(let ch) = locationManager.selectedChannel {
|
if case .location(let ch) = locationManager.selectedChannel {
|
||||||
Button(action: { bookmarks.toggle(ch.geohash) }) {
|
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -1374,7 +1459,6 @@ struct ContentView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||||
.environmentObject(viewModel)
|
|
||||||
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
||||||
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
||||||
}
|
}
|
||||||
@@ -1552,7 +1636,7 @@ private extension ContentView {
|
|||||||
} else if let media = mediaAttachment(for: message) {
|
} else if let media = mediaAttachment(for: message) {
|
||||||
mediaMessageRow(message: message, media: media)
|
mediaMessageRow(message: message, media: media)
|
||||||
} else {
|
} else {
|
||||||
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
textMessageRow(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1616,9 +1700,59 @@ private extension ContentView {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func textMessageRow(_ message: BitchatMessage) -> some View {
|
||||||
|
let cashuTokens = message.content.extractCashuLinks()
|
||||||
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
|
let status = message.deliveryStatus {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLong && cashuTokens.isEmpty {
|
||||||
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
|
Button(labelKey) {
|
||||||
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(Color.blue)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
|
||||||
|
PaymentChipView(paymentType: .lightning(link))
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
|
||||||
|
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||||
|
let urlStr = "cashu:\(enc)"
|
||||||
|
PaymentChipView(paymentType: .cashu(urlStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||||
allMessages: [BitchatMessage],
|
allMessages: [BitchatMessage],
|
||||||
privatePeer: PeerID?,
|
privatePeer: String?,
|
||||||
proxy: ScrollViewProxy) {
|
proxy: ScrollViewProxy) {
|
||||||
let step = TransportConfig.uiWindowStepCount
|
let step = TransportConfig.uiWindowStepCount
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
@@ -1678,19 +1812,7 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowMediaControls: Bool {
|
private var shouldShowMediaControls: Bool {
|
||||||
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
return true
|
|
||||||
}
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
return true
|
|
||||||
case .location:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldShowVoiceControl: Bool {
|
|
||||||
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -1735,23 +1857,17 @@ private extension ContentView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var sendOrMicButton: some View {
|
var sendOrMicButton: some View {
|
||||||
let hasText = !trimmedMessageText.isEmpty
|
let hasText = !trimmedMessageText.isEmpty
|
||||||
if shouldShowVoiceControl {
|
return ZStack {
|
||||||
ZStack {
|
micButtonView
|
||||||
micButtonView
|
.opacity(hasText ? 0 : 1)
|
||||||
.opacity(hasText ? 0 : 1)
|
.allowsHitTesting(!hasText)
|
||||||
.allowsHitTesting(!hasText)
|
|
||||||
sendButtonView(enabled: hasText)
|
|
||||||
.opacity(hasText ? 1 : 0)
|
|
||||||
.allowsHitTesting(hasText)
|
|
||||||
}
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
} else {
|
|
||||||
sendButtonView(enabled: hasText)
|
sendButtonView(enabled: hasText)
|
||||||
.frame(width: 36, height: 36)
|
.opacity(hasText ? 1 : 0)
|
||||||
|
.allowsHitTesting(hasText)
|
||||||
}
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var micButtonView: some View {
|
private var micButtonView: some View {
|
||||||
@@ -1804,7 +1920,6 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startVoiceRecording() {
|
func startVoiceRecording() {
|
||||||
guard shouldShowVoiceControl else { return }
|
|
||||||
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
||||||
isPreparingVoiceNote = true
|
isPreparingVoiceNote = true
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let peerID: PeerID
|
let peerID: String
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -65,12 +65,15 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
|
let statusPeerID: String = {
|
||||||
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
||||||
|
return peerID
|
||||||
|
}()
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||||
if let data = peerID.noiseKey {
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
@@ -81,7 +84,7 @@ struct FingerprintView: View {
|
|||||||
return Strings.unknownPeer()
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -112,7 +115,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -173,6 +176,7 @@ struct FingerprintView: View {
|
|||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
|
let peerID = PeerID(str: peerID)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
@@ -236,6 +240,8 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||||
|
|||||||
@@ -40,31 +40,10 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||||
// High-precision uncertainty: if count is 0 for high-precision levels,
|
|
||||||
// show "?" because presence broadcasting is disabled for privacy.
|
|
||||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
|
||||||
if isHighPrecision && count == 0 {
|
|
||||||
return String(
|
|
||||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
|
||||||
locale: .current,
|
|
||||||
level.displayName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rowTitle(label: level.displayName, count: count)
|
return rowTitle(label: level.displayName, count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||||
// Check precision for bookmarks too
|
|
||||||
let len = geohash.count
|
|
||||||
// Neighborhood=6, Block=7, Building=8+
|
|
||||||
let isHighPrecision = (len >= 6)
|
|
||||||
if isHighPrecision && count == 0 {
|
|
||||||
return String(
|
|
||||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
|
||||||
locale: .current,
|
|
||||||
"#\(geohash)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rowTitle(label: "#\(geohash)", count: count)
|
return rowTitle(label: "#\(geohash)", count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +125,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
@@ -378,7 +360,7 @@ struct LocationChannelsSheet: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) }
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
|
|
||||||
if index < entries.count - 1 {
|
if index < entries.count - 1 {
|
||||||
sectionDivider
|
sectionDivider
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ struct LocationNotesView: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
#endif
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
|
|||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPeer: (PeerID) -> Void
|
let onTapPeer: (String) -> Void
|
||||||
let onToggleFavorite: (PeerID) -> Void
|
let onToggleFavorite: (String) -> Void
|
||||||
let onShowFingerprint: (PeerID) -> Void
|
let onShowFingerprint: (String) -> Void
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.peerID) }) {
|
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private enum RegexCache {
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||||
@@ -23,7 +38,7 @@ extension String {
|
|||||||
|
|
||||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = MessageFormattingEngine.Patterns.cashu
|
let regex = RegexCache.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
@@ -44,19 +59,19 @@ extension String {
|
|||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
// lightning: scheme
|
// lightning: scheme
|
||||||
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
|
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append(s)
|
results.append(s)
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// BOLT11
|
// BOLT11
|
||||||
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
|
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// LNURL bech32
|
// LNURL bech32
|
||||||
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
|
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
|
|||||||
@@ -388,6 +388,10 @@ struct VerificationSheetView: View {
|
|||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
.onDisappear { showingScanner = false }
|
.onDisappear { showingScanner = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import Foundation
|
|||||||
|
|
||||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
private var serviceStorage: [String: [String: Data]] = [:]
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
@@ -29,7 +28,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
storage.removeAll()
|
storage.removeAll()
|
||||||
serviceStorage.removeAll()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,34 +38,4 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
if let data = storage[key] {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .itemNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
storage[key] = keyData
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
|
||||||
if serviceStorage[service] == nil {
|
|
||||||
serviceStorage[service] = [:]
|
|
||||||
}
|
|
||||||
serviceStorage[service]?[key] = data
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(key: String, service: String) -> Data? {
|
|
||||||
serviceStorage[service]?[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(key: String, service: String) {
|
|
||||||
serviceStorage[service]?.removeValue(forKey: key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
//
|
|
||||||
// BLEServiceCoreTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Focused BLEService tests for packet handling behavior.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct BLEServiceCoreTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func duplicatePacket_isDeduped() async {
|
|
||||||
let ble = makeService()
|
|
||||||
let delegate = PublicCaptureDelegate()
|
|
||||||
ble.delegate = delegate
|
|
||||||
|
|
||||||
let sender = PeerID(str: "1122334455667788")
|
|
||||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
|
||||||
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
||||||
|
|
||||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
|
|
||||||
let messages = delegate.publicMessagesSnapshot()
|
|
||||||
#expect(messages.count == 1)
|
|
||||||
#expect(messages.first?.content == "Hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func staleBroadcast_isIgnored() async {
|
|
||||||
let ble = makeService()
|
|
||||||
let delegate = PublicCaptureDelegate()
|
|
||||||
ble.delegate = delegate
|
|
||||||
|
|
||||||
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
|
||||||
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
|
||||||
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
|
||||||
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
||||||
|
|
||||||
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
|
||||||
#expect(!didReceive)
|
|
||||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeService() -> BLEService {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
||||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
|
||||||
BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: Data(hexString: sender.id) ?? Data(),
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: timestamp,
|
|
||||||
payload: Data(content.utf8),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 3
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: nickname,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
lock.lock()
|
|
||||||
publicMessages.append(message)
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
|
||||||
|
|
||||||
func publicMessagesSnapshot() -> [BitchatMessage] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return publicMessages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,7 @@ struct BLEServiceTests {
|
|||||||
service.sendMessage("Hello, world!")
|
service.sendMessage("Hello, world!")
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
#expect(service.sentMessages.count == 1)
|
#expect(service.sentMessages.count == 1)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ struct BLEServiceTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
#expect(service.sentMessages.count == 1)
|
#expect(service.sentMessages.count == 1)
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ struct BLEServiceTests {
|
|||||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ struct BLEServiceTests {
|
|||||||
service.simulateIncomingMessage(incomingMessage)
|
service.simulateIncomingMessage(incomingMessage)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ struct BLEServiceTests {
|
|||||||
service.simulateIncomingPacket(packet)
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ struct BLEServiceTests {
|
|||||||
service.sendMessage("Test delivery")
|
service.sendMessage("Test delivery")
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ struct BLEServiceTests {
|
|||||||
service.simulateIncomingPacket(packet)
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelDeliveryStatusTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel delivery status state machine.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delivery Status Tests
|
|
||||||
|
|
||||||
struct ChatViewModelDeliveryStatusTests {
|
|
||||||
|
|
||||||
// MARK: - Status Transition Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_noDowngrade_readToDelivered() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-1"
|
|
||||||
|
|
||||||
// Setup: create a message with .read status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .read(by: "Peer", at: Date())
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: try to downgrade to .delivered
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
|
||||||
|
|
||||||
// Assert: status should remain .read (no downgrade)
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .read = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-2"
|
|
||||||
|
|
||||||
// Setup: create a message with .sent status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .sent
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: upgrade to .delivered
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
|
||||||
|
|
||||||
// Assert: status should be .delivered
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .delivered = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-3"
|
|
||||||
|
|
||||||
// Setup: create a message with .delivered status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: upgrade to .read
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
|
||||||
|
|
||||||
// Assert: status should be .read
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .read = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Read Receipt Handling
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didReceiveReadReceipt_updatesMessageStatus() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-4"
|
|
||||||
|
|
||||||
// Setup: create a message with .sent status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .sent
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: receive read receipt
|
|
||||||
let receipt = ReadReceipt(
|
|
||||||
originalMessageID: messageID,
|
|
||||||
readerID: peerID,
|
|
||||||
readerNickname: "Peer"
|
|
||||||
)
|
|
||||||
viewModel.didReceiveReadReceipt(receipt)
|
|
||||||
|
|
||||||
// Assert: status should be .read
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .read = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Timeline Status Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let messageID = "public-msg-1"
|
|
||||||
|
|
||||||
// Setup: add a message to public timeline with .sending status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Public message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: false,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
viewModel.messages.append(message)
|
|
||||||
|
|
||||||
// Action: update to .sent
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
|
||||||
#expect({
|
|
||||||
if case .sent = updatedMessage?.deliveryStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Status Rank Tests (for deduplication)
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func statusRank_orderingIsCorrect() async {
|
|
||||||
// This tests the implicit ordering used in refreshVisibleMessages
|
|
||||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
|
||||||
|
|
||||||
let statuses: [DeliveryStatus] = [
|
|
||||||
.failed(reason: "test"),
|
|
||||||
.sending,
|
|
||||||
.sent,
|
|
||||||
.partiallyDelivered(reached: 1, total: 3),
|
|
||||||
.delivered(to: "B", at: Date()),
|
|
||||||
.read(by: "C", at: Date())
|
|
||||||
]
|
|
||||||
|
|
||||||
// Verify each status has a logical progression
|
|
||||||
// This is more of a documentation test to ensure the ranking logic is understood
|
|
||||||
for (index, status) in statuses.enumerated() {
|
|
||||||
switch status {
|
|
||||||
case .failed: #expect(index == 0)
|
|
||||||
case .sending: #expect(index == 1)
|
|
||||||
case .sent: #expect(index == 2)
|
|
||||||
case .partiallyDelivered: #expect(index == 3)
|
|
||||||
case .delivered: #expect(index == 4)
|
|
||||||
case .read: #expect(index == 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelExtensionsTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel extensions (PrivateChat, Nostr, Tor).
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Chat Extension Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPrivateChatExtensionTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivateMessage_mesh_storesAndSends() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
// Use valid hex string for PeerID (32 bytes = 64 hex chars for Noise key usually, or just valid hex)
|
|
||||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
|
||||||
let peerID = PeerID(str: validHex)
|
|
||||||
|
|
||||||
// Simulate connection
|
|
||||||
transport.connectedPeers.insert(peerID)
|
|
||||||
transport.peerNicknames[peerID] = "MeshUser"
|
|
||||||
|
|
||||||
viewModel.sendPrivateMessage("Hello Mesh", to: peerID)
|
|
||||||
|
|
||||||
// Verify transport was called
|
|
||||||
// Note: MockTransport stores sent messages
|
|
||||||
// Since sendPrivateMessage delegates to MessageRouter which delegates to Transport...
|
|
||||||
// We need to ensure MessageRouter is using our MockTransport.
|
|
||||||
// ChatViewModel init sets up MessageRouter with the passed transport.
|
|
||||||
|
|
||||||
// Wait for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Verify message stored locally
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
#expect(viewModel.privateChats[peerID]?.first?.content == "Hello Mesh")
|
|
||||||
|
|
||||||
// Verify message sent to transport (MockTransport captures sendPrivateMessage)
|
|
||||||
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
|
||||||
// Check MockTransport implementation... it might need update or verification
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
|
||||||
let peerID = PeerID(str: validHex)
|
|
||||||
|
|
||||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
|
||||||
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .failed = status { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_storesMessage() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "SENDER_001")
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-1",
|
|
||||||
sender: "Sender",
|
|
||||||
content: "Private Content",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simulate receiving a private message via the handlePrivateMessage extension method
|
|
||||||
viewModel.handlePrivateMessage(message)
|
|
||||||
|
|
||||||
// Verify stored
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
#expect(viewModel.privateChats[peerID]?.first?.content == "Private Content")
|
|
||||||
|
|
||||||
// Verify notification trigger (unread count should increase if not viewing)
|
|
||||||
#expect(viewModel.unreadPrivateMessages.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_deduplicates() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "SENDER_001")
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-1",
|
|
||||||
sender: "Sender",
|
|
||||||
content: "Content",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
viewModel.handlePrivateMessage(message)
|
|
||||||
viewModel.handlePrivateMessage(message) // Duplicate
|
|
||||||
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_sendsReadReceipt_whenViewing() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "SENDER_001")
|
|
||||||
|
|
||||||
// Set as currently viewing
|
|
||||||
viewModel.selectedPrivateChatPeer = peerID
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-1",
|
|
||||||
sender: "Sender",
|
|
||||||
content: "Content",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
viewModel.handlePrivateMessage(message)
|
|
||||||
|
|
||||||
// Should NOT be marked unread
|
|
||||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func migratePrivateChats_consolidatesHistory_onFingerprintMatch() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let oldPeerID = PeerID(str: "OLD_PEER")
|
|
||||||
let newPeerID = PeerID(str: "NEW_PEER")
|
|
||||||
let fingerprint = "fp_123"
|
|
||||||
|
|
||||||
// Setup old chat
|
|
||||||
let oldMessage = BitchatMessage(
|
|
||||||
id: "msg-old",
|
|
||||||
sender: "User",
|
|
||||||
content: "Old message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: oldPeerID
|
|
||||||
)
|
|
||||||
viewModel.privateChats[oldPeerID] = [oldMessage]
|
|
||||||
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
|
|
||||||
|
|
||||||
// Setup new peer fingerprint
|
|
||||||
viewModel.peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
|
|
||||||
|
|
||||||
// Trigger migration
|
|
||||||
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "User")
|
|
||||||
|
|
||||||
// Verify migration
|
|
||||||
#expect(viewModel.privateChats[newPeerID]?.count == 1)
|
|
||||||
#expect(viewModel.privateChats[newPeerID]?.first?.content == "Old message")
|
|
||||||
#expect(viewModel.privateChats[oldPeerID] == nil) // Old chat removed
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func isMessageBlocked_filtersBlockedUsers() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let blockedPeerID = PeerID(str: "BLOCKED_PEER")
|
|
||||||
|
|
||||||
// Block the peer
|
|
||||||
// MockIdentityManager stores state based on fingerprint
|
|
||||||
// We need to map peerID to a fingerprint
|
|
||||||
viewModel.peerIDToPublicKeyFingerprint[blockedPeerID] = "fp_blocked"
|
|
||||||
viewModel.identityManager.setBlocked("fp_blocked", isBlocked: true)
|
|
||||||
|
|
||||||
// Also ensure UnifiedPeerService can resolve the fingerprint.
|
|
||||||
// UnifiedPeerService uses its own cache or delegates to meshService/Peer list.
|
|
||||||
// Since we are mocking, we can't easily inject into UnifiedPeerService's internal cache.
|
|
||||||
// However, ChatViewModel's isMessageBlocked uses:
|
|
||||||
// 1. isPeerBlocked(peerID) -> unifiedPeerService.isBlocked(peerID) -> getFingerprint -> identityManager.isBlocked
|
|
||||||
|
|
||||||
// We need UnifiedPeerService.getFingerprint(for: blockedPeerID) to return "fp_blocked"
|
|
||||||
// UnifiedPeerService tries: cache -> meshService -> getPeer
|
|
||||||
|
|
||||||
// Option 1: Mock the transport (meshService) to return the fingerprint
|
|
||||||
// (viewModel.transport is MockTransport, but UnifiedPeerService holds a reference to it)
|
|
||||||
// Check if MockTransport has `getFingerprint`
|
|
||||||
|
|
||||||
// If not, we might need to rely on the fallback: ChatViewModel.isMessageBlocked also checks Nostr blocks.
|
|
||||||
|
|
||||||
// Let's assume MockTransport needs `getFingerprint` implementation or update it.
|
|
||||||
// For now, let's try to verify if `MockTransport` supports `getFingerprint`.
|
|
||||||
|
|
||||||
// Actually, let's just use the Nostr block path which is simpler and also tested here.
|
|
||||||
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
|
||||||
|
|
||||||
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
|
||||||
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
|
|
||||||
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
|
||||||
|
|
||||||
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
|
||||||
// Or ensure the logic covers it.
|
|
||||||
// The logic is:
|
|
||||||
// if peerID.isGeoChat || peerID.isGeoDM { check nostr }
|
|
||||||
// We need a peerID that looks like geo.
|
|
||||||
|
|
||||||
let geoPeerID = PeerID(nostr_: hexPubkey)
|
|
||||||
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
|
|
||||||
|
|
||||||
let geoMessage = BitchatMessage(
|
|
||||||
id: "msg-geo-blocked",
|
|
||||||
sender: "BlockedGeoUser",
|
|
||||||
content: "Spam",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: geoPeerID
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(viewModel.isMessageBlocked(geoMessage))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Extension Tests
|
|
||||||
|
|
||||||
struct ChatViewModelNostrExtensionTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func switchLocationChannel_mesh_clearsGeo() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup some geo state
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
#expect(viewModel.currentGeohash == "u4pruydq")
|
|
||||||
|
|
||||||
// Switch to mesh
|
|
||||||
viewModel.switchLocationChannel(to: .mesh)
|
|
||||||
|
|
||||||
#expect(viewModel.activeChannel == .mesh)
|
|
||||||
#expect(viewModel.currentGeohash == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
|
||||||
|
|
||||||
LocationChannelManager.shared.select(channel)
|
|
||||||
defer { LocationChannelManager.shared.select(.mesh) }
|
|
||||||
|
|
||||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
|
||||||
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
|
||||||
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "pub1",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Hello Geo"
|
|
||||||
)
|
|
||||||
event.id = "evt1"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
let didAppend = await TestHelpers.waitUntil({
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
|
||||||
})
|
|
||||||
#expect(didAppend)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
|
||||||
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Self echo"
|
|
||||||
)
|
|
||||||
event.id = "evt-self"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleNostrEvent_skipsBlockedSender() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
|
||||||
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: blockedPubkey,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Blocked"
|
|
||||||
)
|
|
||||||
event.id = "evt-blocked"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.deduplicationService.recordNostrEvent("evt-cache")
|
|
||||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Queue Tests
|
|
||||||
|
|
||||||
struct ChatViewModelGeohashQueueTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.addGeohashOnlySystemMessage("Queued system")
|
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
#expect(viewModel.messages.contains { $0.content == "Queued system" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - GeoDM Tests
|
|
||||||
|
|
||||||
struct ChatViewModelGeoDMTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
|
||||||
let messageID = "pm-1"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
|
||||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
|
||||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
|
||||||
|
|
||||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
|
||||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
|
||||||
|
|
||||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
|
||||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelRefactoringTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Pinning tests to characterize ChatViewModel behavior before refactoring.
|
|
||||||
// These tests act as a safety net to ensure we don't break existing functionality.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct ChatViewModelRefactoringTests {
|
|
||||||
|
|
||||||
// Helper to setup the environment
|
|
||||||
@MainActor
|
|
||||||
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport, identityManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Command Processor Integration "Pinning"
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func command_msg_routesToTransport() async throws {
|
|
||||||
let (viewModel, transport, _) = makePinnedViewModel()
|
|
||||||
|
|
||||||
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
|
|
||||||
let peerID = PeerID(str: "0000000000000001")
|
|
||||||
transport.simulateConnect(peerID, nickname: "alice")
|
|
||||||
|
|
||||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didResolve)
|
|
||||||
|
|
||||||
// Action: User types /msg command
|
|
||||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didSend)
|
|
||||||
|
|
||||||
// Assert:
|
|
||||||
// 1. Should NOT go to public transport
|
|
||||||
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
|
|
||||||
|
|
||||||
// 2. Should go to private transport logic
|
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
|
||||||
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
|
|
||||||
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func command_block_updatesIdentity() async throws {
|
|
||||||
let (viewModel, transport, identity) = makePinnedViewModel()
|
|
||||||
|
|
||||||
// Setup: Use simulateConnect
|
|
||||||
let peerID = PeerID(str: "0000000000000002")
|
|
||||||
// Mock the fingerprint so the block command finds it
|
|
||||||
transport.peerFingerprints[peerID] = "fingerprint_123"
|
|
||||||
transport.simulateConnect(peerID, nickname: "troll")
|
|
||||||
|
|
||||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didResolve)
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.sendMessage("/block @troll")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
// Verify identity manager was called to block "fingerprint_123"
|
|
||||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Routing Logic
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func routing_incomingPrivateMessage_addsToPrivateChats() async {
|
|
||||||
let (viewModel, _, _) = makePinnedViewModel()
|
|
||||||
let senderID = PeerID(str: "sender_1")
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg_1",
|
|
||||||
sender: "bob",
|
|
||||||
content: "Secret",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "me",
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action: Simulate incoming private message
|
|
||||||
viewModel.didReceiveMessage(message)
|
|
||||||
|
|
||||||
// Wait for async processing with proper timeout
|
|
||||||
let found = await TestHelpers.waitUntil(
|
|
||||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
|
||||||
timeout: TestConstants.defaultTimeout
|
|
||||||
)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
#expect(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func routing_incomingPublicMessage_addsToPublicTimeline() async {
|
|
||||||
let (viewModel, _, _) = makePinnedViewModel()
|
|
||||||
let senderID = PeerID(str: "sender_2")
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg_2",
|
|
||||||
sender: "charlie",
|
|
||||||
content: "Public Hi",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.didReceiveMessage(message)
|
|
||||||
|
|
||||||
// Wait for async processing with proper timeout
|
|
||||||
let found = await TestHelpers.waitUntil(
|
|
||||||
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
|
|
||||||
timeout: TestConstants.defaultTimeout
|
|
||||||
)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
#expect(found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel using MockTransport for isolation.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization Tests
|
|
||||||
|
|
||||||
struct ChatViewModelInitializationTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_setsDelegate() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// The viewModel should set itself as the transport delegate
|
|
||||||
#expect(transport.delegate === viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_startsServices() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Services should be started during init
|
|
||||||
#expect(transport.startServicesCallCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_hasEmptyMessageList() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Initial messages may include system messages, but should be limited
|
|
||||||
#expect(viewModel.messages.count < 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_setsNickname() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Nickname should be set during init
|
|
||||||
#expect(!transport.myNickname.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Sending Tests
|
|
||||||
|
|
||||||
struct ChatViewModelSendingTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_delegatesToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("Hello World")
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.count == 1)
|
|
||||||
#expect(transport.sentMessages.first?.content == "Hello World")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_emptyContent_ignored() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("")
|
|
||||||
viewModel.sendMessage(" ")
|
|
||||||
viewModel.sendMessage("\n\t")
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_withMentions_sendsContent() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("Hello @alice")
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.count == 1)
|
|
||||||
#expect(transport.sentMessages.first?.content == "Hello @alice")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_command_notSentToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("/help")
|
|
||||||
|
|
||||||
// Commands are processed locally, not sent to transport
|
|
||||||
#expect(transport.sentMessages.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Command Handling Tests
|
|
||||||
|
|
||||||
struct ChatViewModelCommandTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_commandsNotSentToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let commands = ["/nick bob", "/who", "/help", "/clear"]
|
|
||||||
|
|
||||||
for command in commands {
|
|
||||||
transport.resetRecordings()
|
|
||||||
viewModel.sendMessage(command)
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.isEmpty)
|
|
||||||
#expect(transport.sentPrivateMessages.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timeline Cap Tests
|
|
||||||
|
|
||||||
struct ChatViewModelTimelineCapTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_trimsTimelineToCap() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let total = TransportConfig.meshTimelineCap + 5
|
|
||||||
|
|
||||||
for i in 0..<total {
|
|
||||||
viewModel.sendMessage("cap-msg-\(i)")
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
|
|
||||||
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Receiving Tests
|
|
||||||
|
|
||||||
struct ChatViewModelReceivingTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didReceiveMessage_callsDelegate() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-001",
|
|
||||||
sender: "Alice",
|
|
||||||
content: "Hello from Alice",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: PeerID(str: "PEER001"),
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
transport.simulateIncomingMessage(message)
|
|
||||||
|
|
||||||
// Give time for Task and pipeline processing
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
|
|
||||||
// Message may or may not appear due to rate limiting/pipeline batching
|
|
||||||
// The important thing is no crash and delegate was called
|
|
||||||
#expect(transport.delegate != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didReceivePublicMessage_addsToTimeline() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateIncomingPublicMessage(
|
|
||||||
from: PeerID(str: "PEER002"),
|
|
||||||
nickname: "Bob",
|
|
||||||
content: "Public hello from Bob",
|
|
||||||
timestamp: Date(),
|
|
||||||
messageID: "pub-001"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Give time for async Task and pipeline processing
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Rate Limiting Tests
|
|
||||||
|
|
||||||
struct ChatViewModelRateLimitingTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePublicMessage_rateLimitsBurstBySender() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let senderID = PeerID(str: "1122334455667788")
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "rate-\(i)",
|
|
||||||
sender: "Spammer",
|
|
||||||
content: "rate-msg-\(i)",
|
|
||||||
timestamp: now,
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
viewModel.handlePublicMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
|
|
||||||
#expect(burstMessages.count == 5)
|
|
||||||
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer Connection Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didConnectToPeer_notifiesDelegate() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "NEWPEER")
|
|
||||||
|
|
||||||
transport.simulateConnect(peerID, nickname: "NewUser")
|
|
||||||
|
|
||||||
#expect(transport.connectedPeers.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didDisconnectFromPeer_notifiesDelegate() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "OLDPEER")
|
|
||||||
|
|
||||||
transport.simulateConnect(peerID, nickname: "OldUser")
|
|
||||||
transport.simulateDisconnect(peerID)
|
|
||||||
|
|
||||||
#expect(!transport.connectedPeers.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func isPeerConnected_delegatesToTransport() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "TESTPEER")
|
|
||||||
|
|
||||||
// Not connected initially
|
|
||||||
#expect(!transport.isPeerConnected(peerID))
|
|
||||||
|
|
||||||
transport.connectedPeers.insert(peerID)
|
|
||||||
|
|
||||||
#expect(transport.isPeerConnected(peerID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Deduplication Integration Tests
|
|
||||||
//
|
|
||||||
// Note: Detailed deduplication logic is tested in MessageDeduplicationServiceTests.
|
|
||||||
// These tests verify that ChatViewModel has a deduplication service configured.
|
|
||||||
|
|
||||||
struct ChatViewModelDeduplicationTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deduplicationService_isConfigured() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Verify the deduplication service is available and functional
|
|
||||||
// by checking that we can record and query content
|
|
||||||
let testContent = "Test dedup content \(UUID().uuidString)"
|
|
||||||
let testDate = Date()
|
|
||||||
|
|
||||||
viewModel.deduplicationService.recordContent(testContent, timestamp: testDate)
|
|
||||||
|
|
||||||
let retrieved = viewModel.deduplicationService.contentTimestamp(for: testContent)
|
|
||||||
#expect(retrieved == testDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deduplicationService_normalizedKey_consistent() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
let content = "Hello World"
|
|
||||||
let key1 = viewModel.deduplicationService.normalizedContentKey(content)
|
|
||||||
let key2 = viewModel.deduplicationService.normalizedContentKey(content)
|
|
||||||
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Chat Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPrivateChatTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivateMessage_delegatesToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let recipientID = PeerID(str: "RECIPIENT")
|
|
||||||
|
|
||||||
// Set up connected peer for routing
|
|
||||||
transport.connectedPeers.insert(recipientID)
|
|
||||||
transport.peerNicknames[recipientID] = "Recipient"
|
|
||||||
|
|
||||||
viewModel.sendPrivateMessage("Secret message", to: recipientID)
|
|
||||||
|
|
||||||
// The message routing depends on connection state and other factors
|
|
||||||
// At minimum, it should not crash
|
|
||||||
#expect(true) // If we get here without crash, the test passes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Chat Selection Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPrivateChatSelectionTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerA = PeerID(str: "PEER_A")
|
|
||||||
let peerB = PeerID(str: "PEER_B")
|
|
||||||
|
|
||||||
let older = Date().addingTimeInterval(-120)
|
|
||||||
let newer = Date().addingTimeInterval(-30)
|
|
||||||
|
|
||||||
viewModel.privateChats = [
|
|
||||||
peerA: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "a-1",
|
|
||||||
sender: "A",
|
|
||||||
content: "Old",
|
|
||||||
timestamp: older,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerA
|
|
||||||
)
|
|
||||||
],
|
|
||||||
peerB: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "b-1",
|
|
||||||
sender: "B",
|
|
||||||
content: "New",
|
|
||||||
timestamp: newer,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerB
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
|
||||||
|
|
||||||
viewModel.openMostRelevantPrivateChat()
|
|
||||||
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerA = PeerID(str: "PEER_A")
|
|
||||||
let peerB = PeerID(str: "PEER_B")
|
|
||||||
|
|
||||||
let older = Date().addingTimeInterval(-200)
|
|
||||||
let newer = Date().addingTimeInterval(-20)
|
|
||||||
|
|
||||||
viewModel.privateChats = [
|
|
||||||
peerA: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "a-1",
|
|
||||||
sender: "A",
|
|
||||||
content: "Old",
|
|
||||||
timestamp: older,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerA
|
|
||||||
)
|
|
||||||
],
|
|
||||||
peerB: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "b-1",
|
|
||||||
sender: "B",
|
|
||||||
content: "New",
|
|
||||||
timestamp: newer,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerB
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
viewModel.openMostRelevantPrivateChat()
|
|
||||||
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bluetooth State Tests
|
|
||||||
|
|
||||||
struct ChatViewModelBluetoothTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didUpdateBluetoothState_poweredOn_noAlert() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateBluetoothStateChange(.poweredOn)
|
|
||||||
|
|
||||||
// Give time for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(!viewModel.showBluetoothAlert)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didUpdateBluetoothState_poweredOff_showsAlert() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateBluetoothStateChange(.poweredOff)
|
|
||||||
|
|
||||||
// Give time for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.showBluetoothAlert)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didUpdateBluetoothState_unauthorized_showsAlert() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateBluetoothStateChange(.unauthorized)
|
|
||||||
|
|
||||||
// Give time for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.showBluetoothAlert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Panic Clear Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Set up some state
|
|
||||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
|
||||||
viewModel.messages = [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "panic-1",
|
|
||||||
sender: "Tester",
|
|
||||||
content: "Before",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
]
|
|
||||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "pm-1",
|
|
||||||
sender: "Peer",
|
|
||||||
content: "Secret",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: PeerID(str: "PEER1")
|
|
||||||
)
|
|
||||||
]
|
|
||||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
|
||||||
|
|
||||||
viewModel.panicClearAllData()
|
|
||||||
|
|
||||||
// After panic, emergency disconnect should be called
|
|
||||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
|
||||||
#expect(viewModel.messages.isEmpty)
|
|
||||||
#expect(viewModel.privateChats.isEmpty)
|
|
||||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Service Lifecycle Tests
|
|
||||||
|
|
||||||
struct ChatViewModelLifecycleTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func startServices_calledOnInit() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
#expect(transport.startServicesCallCount == 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelTorTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tor Notification Handler Tests
|
|
||||||
|
|
||||||
struct ChatViewModelTorTests {
|
|
||||||
|
|
||||||
// MARK: - handleTorWillStart Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Precondition: flag should start false
|
|
||||||
#expect(!viewModel.torStatusAnnounced)
|
|
||||||
|
|
||||||
// Action: simulate Tor starting notification
|
|
||||||
viewModel.handleTorWillStart()
|
|
||||||
|
|
||||||
// Wait for Task to complete
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: flag should be set (torEnforced is true in tests)
|
|
||||||
#expect(viewModel.torStatusAnnounced)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: pre-set the flag
|
|
||||||
viewModel.torStatusAnnounced = true
|
|
||||||
|
|
||||||
// Switch to a geohash channel so messages would be visible
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
let initialMessageCount = viewModel.messages.count
|
|
||||||
|
|
||||||
// Action: call handler again
|
|
||||||
viewModel.handleTorWillStart()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: no new message added (flag was already true)
|
|
||||||
#expect(viewModel.messages.count == initialMessageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - handleTorWillRestart Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillRestart_setsPendingFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Precondition
|
|
||||||
#expect(!viewModel.torRestartPending)
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorWillRestart()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
#expect(viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Action: call handler (works regardless of channel)
|
|
||||||
viewModel.handleTorWillRestart()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: flag should be set
|
|
||||||
#expect(viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - handleTorDidBecomeReady Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: simulate restart pending state
|
|
||||||
viewModel.torRestartPending = true
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorDidBecomeReady()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: should clear pending flag
|
|
||||||
#expect(!viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: not restarting, but initial ready not announced yet
|
|
||||||
viewModel.torRestartPending = false
|
|
||||||
viewModel.torInitialReadyAnnounced = false
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorDidBecomeReady()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: should set flag (torEnforced is true in tests)
|
|
||||||
#expect(viewModel.torInitialReadyAnnounced)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: already announced initial ready
|
|
||||||
viewModel.torRestartPending = false
|
|
||||||
viewModel.torInitialReadyAnnounced = true
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
let initialMessageCount = viewModel.messages.count
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorDidBecomeReady()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: no new message
|
|
||||||
#expect(viewModel.messages.count == initialMessageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - handleTorPreferenceChanged Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorPreferenceChanged_resetsAllFlags() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: set all flags
|
|
||||||
viewModel.torStatusAnnounced = true
|
|
||||||
viewModel.torInitialReadyAnnounced = true
|
|
||||||
viewModel.torRestartPending = true
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: all flags reset
|
|
||||||
#expect(!viewModel.torStatusAnnounced)
|
|
||||||
#expect(!viewModel.torInitialReadyAnnounced)
|
|
||||||
#expect(!viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapNotFoundGrammar() {
|
@Test func slapNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap @system")
|
let result = processor.process("/slap @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
@@ -18,7 +18,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func hugNotFoundGrammar() {
|
@Test func hugNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/hug @system")
|
let result = processor.process("/hug @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
@@ -30,7 +30,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapUsageMessage() {
|
@Test func slapUsageMessage() {
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap")
|
let result = processor.process("/slap")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
|
|||||||
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
|
|||||||
// Bob relays private messages for Charlie
|
// Bob relays private messages for Charlie
|
||||||
bob.packetDeliveryHandler = { packet in
|
bob.packetDeliveryHandler = { packet in
|
||||||
if let recipientID = packet.recipientID,
|
if let recipientID = packet.recipientID,
|
||||||
PeerID(data: recipientID) == charlie.peerID {
|
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||||
// Relay to Charlie
|
// Relay to Charlie
|
||||||
var relayPacket = packet
|
var relayPacket = packet
|
||||||
relayPacket.ttl = packet.ttl - 1
|
relayPacket.ttl = packet.ttl - 1
|
||||||
charlie.simulateIncomingPacket(relayPacket)
|
self.charlie.simulateIncomingPacket(relayPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
|
|||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID?.id != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -43,16 +43,17 @@ struct FragmentationTests {
|
|||||||
// Shuffle fragments to simulate out-of-order arrival
|
// Shuffle fragments to simulate out-of-order arrival
|
||||||
let shuffled = fragments.shuffled()
|
let shuffled = fragments.shuffled()
|
||||||
|
|
||||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||||
for (i, fragment) in shuffled.enumerated() {
|
for (i, fragment) in shuffled.enumerated() {
|
||||||
if i > 0 {
|
let delay = 5 * Double(i) * 0.001
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
Task {
|
||||||
|
try await sleep(delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// Allow async processing
|
||||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
@@ -77,16 +78,16 @@ struct FragmentationTests {
|
|||||||
frags.insert(dup, at: 1)
|
frags.insert(dup, at: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
|
||||||
for (i, fragment) in frags.enumerated() {
|
for (i, fragment) in frags.enumerated() {
|
||||||
if i > 0 {
|
let delay = 5 * Double(i) * 0.001
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
Task {
|
||||||
|
try await sleep(delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// Allow async processing
|
||||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
@@ -134,7 +135,7 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
|
try await sleep(1.0)
|
||||||
|
|
||||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||||
#expect(message.content.hasPrefix("[file]"))
|
#expect(message.content.hasPrefix("[file]"))
|
||||||
@@ -195,128 +196,12 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension FragmentationTests {
|
extension FragmentationTests {
|
||||||
/// Thread-safe delegate that supports awaiting message delivery
|
private final class CaptureDelegate: BitchatDelegate {
|
||||||
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
private let lock = NSLock()
|
var receivedMessages: [BitchatMessage] = []
|
||||||
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
|
||||||
private var _receivedMessages: [BitchatMessage] = []
|
|
||||||
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
private var expectedPublicMessageCount: Int = 0
|
|
||||||
private var expectedReceivedMessageCount: Int = 0
|
|
||||||
|
|
||||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return _publicMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
var receivedMessages: [BitchatMessage] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return _receivedMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
lock.lock()
|
receivedMessages.append(message)
|
||||||
_receivedMessages.append(message)
|
|
||||||
let count = _receivedMessages.count
|
|
||||||
let expected = expectedReceivedMessageCount
|
|
||||||
let continuation = receivedMessageContinuation
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
if count >= expected, let cont = continuation {
|
|
||||||
lock.lock()
|
|
||||||
receivedMessageContinuation = nil
|
|
||||||
lock.unlock()
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
|
||||||
lock.lock()
|
|
||||||
_publicMessages.append((peerID, nickname, content))
|
|
||||||
let count = _publicMessages.count
|
|
||||||
let expected = expectedPublicMessageCount
|
|
||||||
let continuation = publicMessageContinuation
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
if count >= expected, let cont = continuation {
|
|
||||||
lock.lock()
|
|
||||||
publicMessageContinuation = nil
|
|
||||||
lock.unlock()
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits for the specified number of public messages to be received
|
|
||||||
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
|
||||||
lock.lock()
|
|
||||||
if _publicMessages.count >= count {
|
|
||||||
lock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expectedPublicMessageCount = count
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
self.lock.lock()
|
|
||||||
// Recheck count after acquiring lock to avoid race condition
|
|
||||||
// where message arrives between initial check and continuation install
|
|
||||||
if self._publicMessages.count >= count {
|
|
||||||
self.lock.unlock()
|
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.publicMessageContinuation = continuation
|
|
||||||
self.lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(for: timeout)
|
|
||||||
throw CancellationError()
|
|
||||||
}
|
|
||||||
try await group.next()
|
|
||||||
group.cancelAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits for the specified number of received messages
|
|
||||||
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
|
||||||
lock.lock()
|
|
||||||
if _receivedMessages.count >= count {
|
|
||||||
lock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expectedReceivedMessageCount = count
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
self.lock.lock()
|
|
||||||
// Recheck count after acquiring lock to avoid race condition
|
|
||||||
// where message arrives between initial check and continuation install
|
|
||||||
if self._receivedMessages.count >= count {
|
|
||||||
self.lock.unlock()
|
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.receivedMessageContinuation = continuation
|
|
||||||
self.lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(for: timeout)
|
|
||||||
throw CancellationError()
|
|
||||||
}
|
|
||||||
try await group.next()
|
|
||||||
group.cancelAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
@@ -324,6 +209,9 @@ extension FragmentationTests {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
|
publicMessages.append((peerID, nickname, content))
|
||||||
|
}
|
||||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashParticipantTrackerTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for GeohashParticipantTracker.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
/// Mock context for testing
|
|
||||||
@MainActor
|
|
||||||
final class MockParticipantContext: GeohashParticipantContext {
|
|
||||||
var blockedPubkeys: Set<String> = []
|
|
||||||
var nicknameMap: [String: String] = [:]
|
|
||||||
var selfPubkey: String?
|
|
||||||
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
|
||||||
let suffix = String(pubkeyHex.suffix(4))
|
|
||||||
if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() {
|
|
||||||
return "me#\(suffix)"
|
|
||||||
}
|
|
||||||
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
|
||||||
return "\(nick)#\(suffix)"
|
|
||||||
}
|
|
||||||
return "anon#\(suffix)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
|
||||||
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct GeohashParticipantTrackerTests {
|
|
||||||
|
|
||||||
// MARK: - Basic Recording Tests
|
|
||||||
|
|
||||||
@Test func recordParticipant_addsToActiveGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_noActiveGeohash_noOp() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
// No active geohash set
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
|
|
||||||
|
|
||||||
// Should not throw or crash
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_specificGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
|
||||||
#expect(tracker.participantCount(for: "geo2") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_updatesLastSeen() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
// Small delay and record again
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
// Should still count as 1 participant (updated, not duplicated)
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_lowercasesPubkey() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "DEADBEEF")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "deadbeef")
|
|
||||||
|
|
||||||
// Should be treated as same participant
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Visible People Tests
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_returnsActiveGeohashParticipants() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_excludesBlockedParticipants() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
context.blockedPubkeys = ["pubkey2"]
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 1)
|
|
||||||
#expect(people.first?.id == "pubkey1")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_usesDisplayNameFromContext() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
context.nicknameMap = ["pubkey1234": "alice"]
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1234")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 1)
|
|
||||||
#expect(people.first?.displayName == "alice#1234")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_sortedByLastSeen() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "older")
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
|
||||||
tracker.recordParticipant(pubkeyHex: "newer")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 2)
|
|
||||||
#expect(people.first?.id == "newer")
|
|
||||||
#expect(people.last?.id == "older")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_emptyWhenNoActiveGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Activity Cutoff Tests
|
|
||||||
|
|
||||||
@Test func participantCount_excludesExpiredEntries() async {
|
|
||||||
// Use a very short cutoff for testing
|
|
||||||
let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
// Should be counted immediately
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
|
|
||||||
// Wait for expiry
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
||||||
|
|
||||||
// Should be expired now
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Remove Participant Tests
|
|
||||||
|
|
||||||
@Test func removeParticipant_removesFromAllGeohashes() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1")
|
|
||||||
|
|
||||||
tracker.removeParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
|
||||||
#expect(tracker.participantCount(for: "geo2") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear Tests
|
|
||||||
|
|
||||||
@Test func clear_removesAllData() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other")
|
|
||||||
|
|
||||||
tracker.clear()
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
|
||||||
#expect(tracker.participantCount(for: "other") == 0)
|
|
||||||
#expect(tracker.visiblePeople.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clearGeohash_removesOnlySpecificGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
|
|
||||||
|
|
||||||
tracker.clear(geohash: "geo1")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "geo1") == 0)
|
|
||||||
#expect(tracker.participantCount(for: "geo2") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Set Active Geohash Tests
|
|
||||||
|
|
||||||
@Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
#expect(!tracker.visiblePeople.isEmpty)
|
|
||||||
|
|
||||||
tracker.setActiveGeohash(nil)
|
|
||||||
|
|
||||||
#expect(tracker.visiblePeople.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func setActiveGeohash_refreshesVisiblePeople() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
// Pre-populate a geohash
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
|
|
||||||
|
|
||||||
// Set it as active
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
#expect(tracker.visiblePeople.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - GeoPerson Tests
|
|
||||||
|
|
||||||
@Test func geoPerson_identifiable() async {
|
|
||||||
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
|
|
||||||
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
|
|
||||||
let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date())
|
|
||||||
|
|
||||||
#expect(person1.id == person2.id)
|
|
||||||
#expect(person1.id != person3.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geoPerson_equatable() async {
|
|
||||||
let date = Date()
|
|
||||||
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
|
|
||||||
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
|
|
||||||
|
|
||||||
#expect(person1 == person2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashPresenceTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for the Geohash Presence (Kind 20001) feature.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - NostrProtocol Presence Event Tests
|
|
||||||
|
|
||||||
struct NostrProtocolPresenceTests {
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_hasCorrectKind() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|
||||||
#expect(event.kind == 20001)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_hasEmptyContent() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.content == "")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Should have exactly one tag: ["g", geohash]
|
|
||||||
#expect(event.tags.count == 1)
|
|
||||||
#expect(event.tags[0] == ["g", "u4pruydq"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_noNicknameTag() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Should NOT contain nickname tag
|
|
||||||
let hasNicknameTag = event.tags.contains { $0.first == "n" }
|
|
||||||
#expect(!hasNicknameTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_usesSenderPubkey() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.pubkey == identity.publicKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_isSigned() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.sig != nil && !event.sig!.isEmpty)
|
|
||||||
#expect(!event.id.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_differentGeohashes() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
|
|
||||||
let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity)
|
|
||||||
let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity)
|
|
||||||
let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity)
|
|
||||||
|
|
||||||
#expect(event1.tags[0][1] == "87")
|
|
||||||
#expect(event2.tags[0][1] == "87yw")
|
|
||||||
#expect(event3.tags[0][1] == "87yw7")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper
|
|
||||||
|
|
||||||
private func makeTestIdentity() throws -> NostrIdentity {
|
|
||||||
// Generate a fresh test identity
|
|
||||||
return try NostrIdentity.generate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NostrFilter Presence Tests
|
|
||||||
|
|
||||||
struct NostrFilterPresenceTests {
|
|
||||||
|
|
||||||
@Test func geohashEphemeral_includesBothKinds() {
|
|
||||||
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
|
||||||
|
|
||||||
#expect(filter.kinds?.contains(20000) == true)
|
|
||||||
#expect(filter.kinds?.contains(20001) == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geohashEphemeral_hasLimit1000() {
|
|
||||||
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
|
||||||
|
|
||||||
#expect(filter.limit == 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geohashEphemeral_respectsSinceParameter() {
|
|
||||||
let since = Date(timeIntervalSince1970: 1700000000)
|
|
||||||
let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since)
|
|
||||||
|
|
||||||
#expect(filter.since == 1700000000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ChatViewModel Presence Handling Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ChatViewModelPresenceHandlingTests {
|
|
||||||
|
|
||||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
// Set up the channel
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
// Create a presence event (kind 20001)
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "presence_evt_1"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
// Handle the event
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Participant should be recorded
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
let initialMessageCount = viewModel.messages.count
|
|
||||||
|
|
||||||
// Create a presence event (kind 20001)
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "presence_evt_2"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Message count should NOT increase
|
|
||||||
#expect(viewModel.messages.count == initialMessageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
// Create a chat event (kind 20000) - NOT presence
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Hello world"
|
|
||||||
)
|
|
||||||
event.id = "chat_evt_1"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Chat messages should also update participant count (not just presence)
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func presenceEvent_hasDifferentKindThanChat() {
|
|
||||||
// Verify the two event kinds are distinct
|
|
||||||
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
|
||||||
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|
|
||||||
#expect(presenceKind != chatKind)
|
|
||||||
#expect(presenceKind == 20001)
|
|
||||||
#expect(chatKind == 20000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
// Create presence event
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "subscribe_presence_evt"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
// subscribeNostrEvent should accept kind 20001
|
|
||||||
viewModel.subscribeNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Should record participant
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let activeGeohash = "u4pruydq"
|
|
||||||
let otherGeohash = "87yw7"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
|
||||||
|
|
||||||
// Create presence event for a DIFFERENT geohash
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "other1234other1234other1234other1234other1234other1234other1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", otherGeohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "other_geohash_presence"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
// Use subscribeNostrEvent with geohash parameter
|
|
||||||
viewModel.subscribeNostrEvent(event, gh: otherGeohash)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Should record for the other geohash
|
|
||||||
let count = viewModel.geohashParticipantCount(for: otherGeohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test Helper
|
|
||||||
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Presence Privacy Tests
|
|
||||||
|
|
||||||
struct GeohashPresencePrivacyTests {
|
|
||||||
|
|
||||||
@Test func allowedPrecisions_onlyLowPrecision() {
|
|
||||||
// The allowed precisions for presence broadcasting should be:
|
|
||||||
// Region (2), Province (4), City (5)
|
|
||||||
// NOT Neighborhood (6), Block (7), Building (8+)
|
|
||||||
|
|
||||||
let regionPrecision = GeohashChannelLevel.region.precision
|
|
||||||
let provincePrecision = GeohashChannelLevel.province.precision
|
|
||||||
let cityPrecision = GeohashChannelLevel.city.precision
|
|
||||||
let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision
|
|
||||||
let blockPrecision = GeohashChannelLevel.block.precision
|
|
||||||
let buildingPrecision = GeohashChannelLevel.building.precision
|
|
||||||
|
|
||||||
#expect(regionPrecision == 2)
|
|
||||||
#expect(provincePrecision == 4)
|
|
||||||
#expect(cityPrecision == 5)
|
|
||||||
#expect(neighborhoodPrecision == 6)
|
|
||||||
#expect(blockPrecision == 7)
|
|
||||||
#expect(buildingPrecision == 8)
|
|
||||||
|
|
||||||
// High precision channels should NOT receive presence broadcasts
|
|
||||||
#expect(neighborhoodPrecision > 5)
|
|
||||||
#expect(blockPrecision > 5)
|
|
||||||
#expect(buildingPrecision > 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geohashLengthDeterminesPrecision() {
|
|
||||||
// Verify geohash length maps to expected precision
|
|
||||||
#expect("87".count == GeohashChannelLevel.region.precision)
|
|
||||||
#expect("87yw".count == GeohashChannelLevel.province.precision)
|
|
||||||
#expect("87yw7".count == GeohashChannelLevel.city.precision)
|
|
||||||
#expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision)
|
|
||||||
#expect("87yw7tc".count == GeohashChannelLevel.block.precision)
|
|
||||||
#expect("87yw7tcx".count == GeohashChannelLevel.building.precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func highPrecisionGeohash_isPrivacySensitive() {
|
|
||||||
// Helper to check if a geohash is "high precision" (privacy sensitive)
|
|
||||||
func isHighPrecision(_ geohash: String) -> Bool {
|
|
||||||
geohash.count >= 6
|
|
||||||
}
|
|
||||||
|
|
||||||
// Low precision - OK to broadcast presence
|
|
||||||
#expect(!isHighPrecision("87")) // region
|
|
||||||
#expect(!isHighPrecision("87yw")) // province
|
|
||||||
#expect(!isHighPrecision("87yw7")) // city
|
|
||||||
|
|
||||||
// High precision - should NOT broadcast presence
|
|
||||||
#expect(isHighPrecision("87yw7t")) // neighborhood
|
|
||||||
#expect(isHighPrecision("87yw7tc")) // block
|
|
||||||
#expect(isHighPrecision("87yw7tcx")) // building
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display Logic Tests
|
|
||||||
|
|
||||||
struct LocationChannelsDisplayLogicTests {
|
|
||||||
|
|
||||||
@Test func displayLogic_highPrecisionZeroCount_showsUnknown() {
|
|
||||||
// Test the logic that determines "?" vs actual count
|
|
||||||
// High precision + count 0 = "?"
|
|
||||||
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .neighborhood,
|
|
||||||
count: 0
|
|
||||||
)
|
|
||||||
#expect(shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_highPrecisionNonZeroCount_showsActual() {
|
|
||||||
// High precision + count > 0 = show actual
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .neighborhood,
|
|
||||||
count: 5
|
|
||||||
)
|
|
||||||
#expect(!shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_lowPrecisionZeroCount_showsActual() {
|
|
||||||
// Low precision + count 0 = show "0" (not "?")
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .city,
|
|
||||||
count: 0
|
|
||||||
)
|
|
||||||
#expect(!shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_lowPrecisionNonZeroCount_showsActual() {
|
|
||||||
// Low precision + count > 0 = show actual
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .region,
|
|
||||||
count: 10
|
|
||||||
)
|
|
||||||
#expect(!shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_allHighPrecisionLevels() {
|
|
||||||
// All high precision levels with 0 should show "?"
|
|
||||||
let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building]
|
|
||||||
|
|
||||||
for level in highPrecisionLevels {
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
|
||||||
#expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_allLowPrecisionLevels() {
|
|
||||||
// All low precision levels with 0 should show actual count
|
|
||||||
let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city]
|
|
||||||
|
|
||||||
for level in lowPrecisionLevels {
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
|
||||||
#expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_bookmarkHighPrecision() {
|
|
||||||
// Bookmarks use geohash length to determine precision
|
|
||||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6
|
|
||||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7
|
|
||||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_bookmarkLowPrecision() {
|
|
||||||
#expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2
|
|
||||||
#expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4
|
|
||||||
#expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers (mirror the logic from LocationChannelsSheet)
|
|
||||||
|
|
||||||
private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool {
|
|
||||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
|
||||||
return isHighPrecision && count == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool {
|
|
||||||
let isHighPrecision = (geohash.count >= 6)
|
|
||||||
return isHighPrecision && count == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Event Kind Tests
|
|
||||||
|
|
||||||
struct NostrEventKindTests {
|
|
||||||
|
|
||||||
@Test func eventKind_geohashPresence_is20001() {
|
|
||||||
#expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eventKind_ephemeralEvent_is20000() {
|
|
||||||
#expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eventKind_presenceIsEphemeral() {
|
|
||||||
// Both 20000 and 20001 are in the ephemeral range (20000-29999)
|
|
||||||
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
|
||||||
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|
|
||||||
#expect(presenceKind >= 20000 && presenceKind < 30000)
|
|
||||||
#expect(chatKind >= 20000 && chatKind < 30000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Participant Tracker Presence Integration Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ParticipantTrackerPresenceTests {
|
|
||||||
|
|
||||||
@Test func recordParticipant_fromPresenceEvent_countsParticipant() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
let geohash = "87yw7"
|
|
||||||
tracker.setActiveGeohash(geohash)
|
|
||||||
|
|
||||||
// Simulate recording from a presence event
|
|
||||||
tracker.recordParticipant(pubkeyHex: "presence_user_1")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: geohash) == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_multiplePresenceEvents_countsUnique() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
let geohash = "87yw7"
|
|
||||||
tracker.setActiveGeohash(geohash)
|
|
||||||
|
|
||||||
// Multiple presence events from same user = 1 participant
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: geohash) == 1)
|
|
||||||
|
|
||||||
// Different user = 2 participants
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_b")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: geohash) == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_nonActiveGeohash_stillCounts() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
// Active geohash is different from where we're recording
|
|
||||||
tracker.setActiveGeohash("active_gh")
|
|
||||||
|
|
||||||
// Record to a non-active geohash (like when sampling nearby channels)
|
|
||||||
tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "other_gh") == 1)
|
|
||||||
#expect(tracker.participantCount(for: "active_gh") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func objectWillChange_firesOnNonActiveGeohashUpdate() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.setActiveGeohash("active_gh")
|
|
||||||
|
|
||||||
var changeCount = 0
|
|
||||||
let cancellable = tracker.objectWillChange.sink { _ in
|
|
||||||
changeCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record to non-active geohash
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh")
|
|
||||||
|
|
||||||
// Should fire objectWillChange even for non-active geohash
|
|
||||||
#expect(changeCount >= 1)
|
|
||||||
|
|
||||||
_ = cancellable // Keep alive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mock for Participant Context (Presence Tests)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class PresenceTestParticipantContext: GeohashParticipantContext {
|
|
||||||
var blockedPubkeys: Set<String> = []
|
|
||||||
var nicknameMap: [String: String] = [:]
|
|
||||||
var selfPubkey: String?
|
|
||||||
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
|
||||||
let suffix = String(pubkeyHex.suffix(4))
|
|
||||||
if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() {
|
|
||||||
return "me#\(suffix)"
|
|
||||||
}
|
|
||||||
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
|
||||||
return "\(nick)#\(suffix)"
|
|
||||||
}
|
|
||||||
return "anon#\(suffix)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
|
||||||
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,14 +7,12 @@ struct GossipSyncManagerTests {
|
|||||||
private let myPeerID = PeerID(str: "0102030405060708")
|
private let myPeerID = PeerID(str: "0102030405060708")
|
||||||
|
|
||||||
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
manager.delegate = delegate
|
manager.delegate = delegate
|
||||||
|
|
||||||
try await confirmation("sync request sent") { sent in
|
try await confirmation("sync request sent") { sent in
|
||||||
delegate.onSend = {
|
delegate.onSend = {
|
||||||
delegate.onSend = nil
|
|
||||||
sent()
|
sent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ struct GossipSyncManagerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
try await sleep(0.002)
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||||
@@ -49,8 +47,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.stalePeerCleanupIntervalSeconds = 0
|
config.stalePeerCleanupIntervalSeconds = 0
|
||||||
config.stalePeerTimeoutSeconds = 5
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let peerHex = "0011223344556677"
|
let peerHex = "0011223344556677"
|
||||||
let senderData = try #require(Data(hexString: peerHex))
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
@@ -95,8 +92,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.stalePeerTimeoutSeconds = 5
|
config.stalePeerTimeoutSeconds = 5
|
||||||
config.maxMessageAgeSeconds = 100
|
config.maxMessageAgeSeconds = 100
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let peerHex = "8899aabbccddeeff"
|
let peerHex = "8899aabbccddeeff"
|
||||||
let senderData = try #require(Data(hexString: peerHex))
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||||
@@ -129,140 +125,16 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func maintenanceEmitsTypedSyncRequests() throws {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.seenCapacity = 10
|
|
||||||
config.fragmentCapacity = 5
|
|
||||||
config.fileTransferCapacity = 4
|
|
||||||
config.messageSyncIntervalSeconds = 1
|
|
||||||
config.fragmentSyncIntervalSeconds = 1
|
|
||||||
config.fileTransferSyncIntervalSeconds = 1
|
|
||||||
config.maintenanceIntervalSeconds = 0
|
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
|
||||||
manager.delegate = delegate
|
|
||||||
|
|
||||||
let sender = try #require(Data(hexString: "1122334455667788"))
|
|
||||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
|
|
||||||
let announcePacket = BitchatPacket(
|
|
||||||
type: MessageType.announce.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data(),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
let messagePacket = BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0x01]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
let fragmentPacket = BitchatPacket(
|
|
||||||
type: MessageType.fragment.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0xAA]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
let filePacket = BitchatPacket(
|
|
||||||
type: MessageType.fileTransfer.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0xBB]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.onPublicPacketSeen(announcePacket)
|
|
||||||
manager.onPublicPacketSeen(messagePacket)
|
|
||||||
manager.onPublicPacketSeen(fragmentPacket)
|
|
||||||
manager.onPublicPacketSeen(filePacket)
|
|
||||||
|
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
|
||||||
|
|
||||||
let sentPackets = delegate.packets
|
|
||||||
#expect(sentPackets.count == 3)
|
|
||||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
|
||||||
#expect(decoded.count == 3)
|
|
||||||
#expect(decoded[0].types == .publicMessages)
|
|
||||||
#expect(decoded[1].types == .fragment)
|
|
||||||
#expect(decoded[2].types == .fileTransfer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.seenCapacity = 5
|
|
||||||
config.fragmentCapacity = 5
|
|
||||||
config.fileTransferCapacity = 0
|
|
||||||
config.messageSyncIntervalSeconds = 0
|
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
|
||||||
manager.delegate = delegate
|
|
||||||
|
|
||||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
|
||||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
|
|
||||||
let messagePacket = BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0x10]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
let fragmentPacket = BitchatPacket(
|
|
||||||
type: MessageType.fragment.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0x20]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.onPublicPacketSeen(messagePacket)
|
|
||||||
manager.onPublicPacketSeen(fragmentPacket)
|
|
||||||
|
|
||||||
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
|
|
||||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
|
||||||
manager.handleRequestSync(from: peer, request: request)
|
|
||||||
|
|
||||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
|
||||||
let sentPackets = delegate.packets
|
|
||||||
#expect(sentPackets.count == 1)
|
|
||||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
var onSend: (() -> Void)?
|
var onSend: (() -> Void)?
|
||||||
private(set) var lastPacket: BitchatPacket?
|
private(set) var lastPacket: BitchatPacket?
|
||||||
private(set) var packets: [BitchatPacket] = []
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
||||||
func sendPacket(_ packet: BitchatPacket) {
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
lastPacket = packet
|
lastPacket = packet
|
||||||
packets.append(packet)
|
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
onSend?()
|
onSend?()
|
||||||
}
|
}
|
||||||
@@ -274,8 +146,4 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
|||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
packet
|
packet
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConnectedPeers() -> [PeerID] {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
//
|
|
||||||
// InputValidatorTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct InputValidatorTests {
|
|
||||||
|
|
||||||
// MARK: - Basic Validation Tests
|
|
||||||
|
|
||||||
@Test func validStringPassesValidation() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
|
|
||||||
#expect(result == "Hello World")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func emptyStringReturnsNil() throws {
|
|
||||||
let result = InputValidator.validateUserString("", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func whitespaceOnlyStringReturnsNil() throws {
|
|
||||||
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringExceedingMaxLengthReturnsNil() throws {
|
|
||||||
let longString = String(repeating: "a", count: 101)
|
|
||||||
let result = InputValidator.validateUserString(longString, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringAtMaxLengthIsAccepted() throws {
|
|
||||||
let exactString = String(repeating: "a", count: 100)
|
|
||||||
let result = InputValidator.validateUserString(exactString, maxLength: 100)
|
|
||||||
#expect(result == exactString)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func whitespaceIsTrimmed() throws {
|
|
||||||
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
|
|
||||||
#expect(result == "Hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Control Character Tests
|
|
||||||
|
|
||||||
@Test func nullCharacterIsRejected() throws {
|
|
||||||
let stringWithNull = "Hello\u{0000}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func bellCharacterIsRejected() throws {
|
|
||||||
let stringWithBell = "Hello\u{0007}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func backspaceCharacterIsRejected() throws {
|
|
||||||
let stringWithBackspace = "Hello\u{0008}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func escapeCharacterIsRejected() throws {
|
|
||||||
let stringWithEscape = "Hello\u{001B}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func deleteCharacterIsRejected() throws {
|
|
||||||
let stringWithDelete = "Hello\u{007F}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func multipleControlCharactersAreRejected() throws {
|
|
||||||
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Unicode and Special Character Tests
|
|
||||||
|
|
||||||
@Test func emojiIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
|
|
||||||
#expect(result == "Hello 👋 World")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func unicodeCharactersAreAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
|
|
||||||
#expect(result == "Hello 世界 مرحبا")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func specialCharactersAreAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
|
|
||||||
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nickname Validation Tests
|
|
||||||
|
|
||||||
@Test func validNicknameIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateNickname("Alice")
|
|
||||||
#expect(result == "Alice")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameWithEmojiIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateNickname("Alice 🚀")
|
|
||||||
#expect(result == "Alice 🚀")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameTooLongIsRejected() throws {
|
|
||||||
let longNickname = String(repeating: "a", count: 51)
|
|
||||||
let result = InputValidator.validateNickname(longNickname)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameAtMaxLengthIsAccepted() throws {
|
|
||||||
let exactNickname = String(repeating: "a", count: 50)
|
|
||||||
let result = InputValidator.validateNickname(exactNickname)
|
|
||||||
#expect(result == exactNickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameWithControlCharacterIsRejected() throws {
|
|
||||||
let result = InputValidator.validateNickname("Alice\u{0000}")
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timestamp Validation Tests
|
|
||||||
// BCH-01-011: Window reduced from ±1 hour to ±5 minutes
|
|
||||||
|
|
||||||
@Test func currentTimestampIsValid() throws {
|
|
||||||
let now = Date()
|
|
||||||
let result = InputValidator.validateTimestamp(now)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampWithinFiveMinutesIsValid() throws {
|
|
||||||
// 2 minutes ago should be valid (within 5-minute window)
|
|
||||||
let twoMinutesAgo = Date().addingTimeInterval(-2 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(twoMinutesAgo)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampThirtyMinutesAgoIsInvalid() throws {
|
|
||||||
// BCH-01-011: 30 minutes is now outside the 5-minute window
|
|
||||||
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampTenMinutesAgoIsInvalid() throws {
|
|
||||||
// 10 minutes is outside the 5-minute window
|
|
||||||
let tenMinutesAgo = Date().addingTimeInterval(-10 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(tenMinutesAgo)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampTenMinutesInFutureIsInvalid() throws {
|
|
||||||
// 10 minutes in future is outside the 5-minute window
|
|
||||||
let tenMinutesFromNow = Date().addingTimeInterval(10 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(tenMinutesFromNow)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampAtFiveMinuteBoundaryIsValid() throws {
|
|
||||||
// Just slightly within the five-minute window (299 seconds)
|
|
||||||
let almostFiveMinutesAgo = Date().addingTimeInterval(-299)
|
|
||||||
let result = InputValidator.validateTimestamp(almostFiveMinutesAgo)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampJustOutsideFiveMinuteWindowIsInvalid() throws {
|
|
||||||
// Just outside the five-minute window (301 seconds)
|
|
||||||
let justOverFiveMinutesAgo = Date().addingTimeInterval(-301)
|
|
||||||
let result = InputValidator.validateTimestamp(justOverFiveMinutesAgo)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Edge Cases
|
|
||||||
|
|
||||||
@Test func singleCharacterStringIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("a", maxLength: 100)
|
|
||||||
#expect(result == "a")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithOnlyNewlinesIsRejected() throws {
|
|
||||||
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
|
|
||||||
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
|
|
||||||
#expect(result == "Hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithLeadingControlCharacterIsRejected() throws {
|
|
||||||
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithTrailingControlCharacterIsRejected() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
//
|
|
||||||
// KeychainErrorHandlingTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
// BCH-01-009: Tests for proper keychain error classification and handling
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct KeychainErrorHandlingTests {
|
|
||||||
|
|
||||||
// MARK: - Error Classification Tests
|
|
||||||
|
|
||||||
@Test func keychainReadResult_successIsNotRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.success(Data([1, 2, 3]))
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.itemNotFound
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_deviceLockedIsRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.deviceLocked
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_authenticationFailedIsRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.authenticationFailed
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_accessDeniedIsNotRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.accessDenied
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_successIsNotRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.success
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_duplicateItemIsRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.duplicateItem
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_deviceLockedIsRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.deviceLocked
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_storageFullIsNotRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.storageFull
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mock Keychain Error Simulation Tests
|
|
||||||
|
|
||||||
@Test func mockKeychain_canSimulateReadErrors() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Simulate access denied error
|
|
||||||
keychain.simulatedReadError = .accessDenied
|
|
||||||
let result = keychain.getIdentityKeyWithResult(forKey: "testKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .accessDenied:
|
|
||||||
// Expected
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected accessDenied, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_canSimulateSaveErrors() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Simulate storage full error
|
|
||||||
keychain.simulatedSaveError = .storageFull
|
|
||||||
let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: "testKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .storageFull:
|
|
||||||
// Expected
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected storageFull, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_returnsItemNotFoundForMissingKey() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let result = keychain.getIdentityKeyWithResult(forKey: "nonExistentKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected itemNotFound, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_returnsSuccessForExistingKey() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let testData = Data([1, 2, 3, 4, 5])
|
|
||||||
|
|
||||||
// First save the key
|
|
||||||
_ = keychain.saveIdentityKey(testData, forKey: "existingKey")
|
|
||||||
|
|
||||||
// Now read it back
|
|
||||||
let result = keychain.getIdentityKeyWithResult(forKey: "existingKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let data):
|
|
||||||
#expect(data == testData)
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected success, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_saveWithResultStoresData() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let testData = Data([10, 20, 30])
|
|
||||||
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: "newKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
// Verify data was stored
|
|
||||||
let readResult = keychain.getIdentityKeyWithResult(forKey: "newKey")
|
|
||||||
switch readResult {
|
|
||||||
case .success(let data):
|
|
||||||
#expect(data == testData)
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected to read back saved data")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected save success, got \(saveResult)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NoiseEncryptionService Integration Tests
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Create service with empty keychain - should generate new identity
|
|
||||||
let service = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
// Should have generated and saved keys
|
|
||||||
#expect(service.getStaticPublicKeyData().count == 32)
|
|
||||||
#expect(service.getSigningPublicKeyData().count == 32)
|
|
||||||
|
|
||||||
// Keys should be persisted
|
|
||||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
|
||||||
switch noiseKeyResult {
|
|
||||||
case .success:
|
|
||||||
// Expected - key was saved
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected noise key to be saved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_loadsExistingIdentity() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Create first service to generate identity
|
|
||||||
let service1 = NoiseEncryptionService(keychain: keychain)
|
|
||||||
let originalPublicKey = service1.getStaticPublicKeyData()
|
|
||||||
let originalSigningKey = service1.getSigningPublicKeyData()
|
|
||||||
|
|
||||||
// Create second service - should load same identity
|
|
||||||
let service2 = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
#expect(service2.getStaticPublicKeyData() == originalPublicKey)
|
|
||||||
#expect(service2.getSigningPublicKeyData() == originalSigningKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
keychain.simulatedReadError = .accessDenied
|
|
||||||
|
|
||||||
// Service should still initialize with ephemeral key
|
|
||||||
let service = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
// Should have an identity (ephemeral)
|
|
||||||
#expect(service.getStaticPublicKeyData().count == 32)
|
|
||||||
#expect(service.getSigningPublicKeyData().count == 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
keychain.simulatedReadError = .deviceLocked
|
|
||||||
|
|
||||||
// Service should still initialize with ephemeral key
|
|
||||||
let service = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
// Should have an identity (ephemeral)
|
|
||||||
#expect(service.getStaticPublicKeyData().count == 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper error type for tests
|
|
||||||
private struct KeychainTestError: Error, CustomStringConvertible {
|
|
||||||
let message: String
|
|
||||||
init(_ message: String) { self.message = message }
|
|
||||||
var description: String { message }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
//import Foundation
|
||||||
|
//import XCTest
|
||||||
|
//@testable import bitchat
|
||||||
|
//
|
||||||
|
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||||
|
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||||
|
//
|
||||||
|
//final class LocalizationCatalogTests: XCTestCase {
|
||||||
|
// // Ensures every app locale includes exactly the same keys as Base.
|
||||||
|
// func testAppCatalogLocaleParity() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// assertLocaleParity(context: context, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Verifies format placeholders stay consistent across app locales.
|
||||||
|
// func testAppCatalogPlaceholderConsistency() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Guards a core set of app strings from going empty per locale.
|
||||||
|
// func testAppPrimaryKeysNonEmpty() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let primaryKeys = try loadPrimaryKeys().app
|
||||||
|
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ensures every share extension locale matches Base key coverage.
|
||||||
|
// func testShareExtensionCatalogLocaleParity() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Verifies share extension placeholders align across locales.
|
||||||
|
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Confirms critical share extension strings remain non-empty per locale.
|
||||||
|
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||||
|
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Validates that configured locales contain expected string values.
|
||||||
|
// func testLocalizationExpectedValues() throws {
|
||||||
|
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let config = try loadPrimaryKeys()
|
||||||
|
//
|
||||||
|
// guard let testLocales = config.testLocales else {
|
||||||
|
// // If no testLocales specified, skip this test
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let expectedValues = config.expectedValues else {
|
||||||
|
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Loop through each locale to test
|
||||||
|
// for locale in testLocales {
|
||||||
|
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||||
|
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Test each expected key/value pair for this locale
|
||||||
|
// for (key, expectedValue) in localeExpectedValues {
|
||||||
|
// if config.app.contains(key) {
|
||||||
|
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||||
|
// } else if config.shareExtension.contains(key) {
|
||||||
|
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ensures configured test locales are present and complete.
|
||||||
|
// func testConfiguredLocalesCompleteness() throws {
|
||||||
|
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let config = try loadPrimaryKeys()
|
||||||
|
//
|
||||||
|
// guard let testLocales = config.testLocales else {
|
||||||
|
// // If no testLocales specified, skip this test
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// let baseLocale = appContext.baseLocale
|
||||||
|
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||||
|
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||||
|
//
|
||||||
|
// for locale in testLocales {
|
||||||
|
// // Skip base locale comparison with itself
|
||||||
|
// if locale == baseLocale { continue }
|
||||||
|
//
|
||||||
|
// // Verify locale is present in both catalogs
|
||||||
|
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||||
|
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||||
|
//
|
||||||
|
// // Verify locale has same number of keys as base locale
|
||||||
|
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||||
|
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||||
|
//
|
||||||
|
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||||
|
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Assertions
|
||||||
|
//
|
||||||
|
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// let baseLocale = context.baseLocale
|
||||||
|
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||||
|
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// let baseLocale = context.baseLocale
|
||||||
|
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||||
|
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// guard locale != baseLocale else { continue }
|
||||||
|
// for key in baseSignatures.keys.sorted() {
|
||||||
|
// guard let baseMap = baseSignatures[key] else {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// guard let localeMap = localeSignatures[key] else {
|
||||||
|
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// for path in baseMap.keys.sorted() {
|
||||||
|
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||||
|
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||||
|
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// for (localePath, localeTokens) in localeMap {
|
||||||
|
// guard baseMap[localePath] == nil else { continue }
|
||||||
|
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||||
|
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||||
|
// let actual = normalizedPlaceholders(localeTokens)
|
||||||
|
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// for key in keys {
|
||||||
|
// guard let entry = context.catalog.strings[key] else {
|
||||||
|
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// for locale in context.locales.sorted() {
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// let segments = gatherSegments(from: unit)
|
||||||
|
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// for segment in segments {
|
||||||
|
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// guard let entry = context.catalog.strings[key] else {
|
||||||
|
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // For simple strings (non-pluralized)
|
||||||
|
// if let actualValue = unit.value {
|
||||||
|
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||||
|
// } else {
|
||||||
|
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Loading
|
||||||
|
//
|
||||||
|
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||||
|
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||||
|
// let locales = catalog.locales
|
||||||
|
// let baseLocale = catalog.sourceLanguage
|
||||||
|
// var keysByLocale: [String: Set<String>] = [:]
|
||||||
|
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||||
|
//
|
||||||
|
// for locale in locales {
|
||||||
|
// var localeKeys: Set<String> = []
|
||||||
|
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||||
|
// for (key, entry) in catalog.strings {
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// localeKeys.insert(key)
|
||||||
|
// let segments = gatherSegments(from: unit)
|
||||||
|
// var pathMap: [String: [String]] = [:]
|
||||||
|
// for segment in segments {
|
||||||
|
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||||
|
// }
|
||||||
|
// localePlaceholders[key] = pathMap
|
||||||
|
// }
|
||||||
|
// keysByLocale[locale] = localeKeys
|
||||||
|
// placeholderSignature[locale] = localePlaceholders
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||||
|
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||||
|
// let data = try Data(contentsOf: url)
|
||||||
|
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||||
|
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||||
|
// let data = try Data(contentsOf: url)
|
||||||
|
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Regression Tests
|
||||||
|
//
|
||||||
|
// /// Helper method to access the app bundle for localization testing.
|
||||||
|
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||||
|
// /// the test bundle, preventing false positives where localization keys are
|
||||||
|
// /// returned instead of translated strings.
|
||||||
|
// private func appBundle() -> Bundle {
|
||||||
|
// Bundle(for: ChatViewModel.self)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /// Tests to prevent regression of the pluralization format string issue
|
||||||
|
// /// These tests ensure that String(localized:) properly handles
|
||||||
|
// /// pluralized format strings with %#@variable@ syntax
|
||||||
|
// func testPluralizedFormatStringsDoNotCrash() {
|
||||||
|
// // These are the exact calls that were causing runtime errors
|
||||||
|
//
|
||||||
|
// // Test 1: content.accessibility.people_count with Int argument
|
||||||
|
// XCTAssertNoThrow({
|
||||||
|
// let result = String(
|
||||||
|
// localized: "content.accessibility.people_count",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Accessibility label announcing number of people in header"
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||||
|
// // Verify we're getting actual localized content, not just the key
|
||||||
|
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||||
|
// }(), "People count localization should not throw")
|
||||||
|
//
|
||||||
|
// // Test 2: location_notes.header with String and Int arguments
|
||||||
|
// XCTAssertNoThrow({
|
||||||
|
// let result = String(
|
||||||
|
// localized: "location_notes.header",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Header displaying the geohash and localized note count"
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||||
|
// // Verify we're getting actual localized content, not just the key
|
||||||
|
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||||
|
// }(), "Location notes header localization should not throw")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testFormatStringArgumentMatching() {
|
||||||
|
// // Verify that the format strings properly handle their expected arguments
|
||||||
|
//
|
||||||
|
// // People count expects: %#@people@ format with Int
|
||||||
|
// let peopleResult = String(
|
||||||
|
// localized: "content.accessibility.people_count",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Test"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Should not show format specifiers in the base string
|
||||||
|
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||||
|
//
|
||||||
|
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||||
|
// let notesResult = String(
|
||||||
|
// localized: "location_notes.header",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Test"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testPluralizedStringsWithArguments() {
|
||||||
|
// // Test the pluralized strings with actual format arguments
|
||||||
|
//
|
||||||
|
// // Test people count with different values
|
||||||
|
// let testCases = [0, 1, 2, 5]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Just verify it doesn't crash and returns a reasonable string
|
||||||
|
// XCTAssertFalse(result.isEmpty,
|
||||||
|
// "People count should return non-empty string for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain the count number for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocationNotesHeaderWithArguments() {
|
||||||
|
// let geohash = "abc123"
|
||||||
|
// let testCases = [0, 1, 2, 10]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// geohash, count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Verify basic structure is maintained
|
||||||
|
// XCTAssertTrue(result.contains(geohash),
|
||||||
|
// "Result should contain geohash for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain count for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocationChannelsRowTitleWithArguments() {
|
||||||
|
// let label = "test-channel"
|
||||||
|
// let testCases = [0, 1, 2, 5]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// label, count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Verify basic structure is maintained
|
||||||
|
// XCTAssertTrue(result.contains(label),
|
||||||
|
// "Result should contain label for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain count for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testNoArgumentsLocalization() {
|
||||||
|
// // Test that strings without arguments still work
|
||||||
|
// let result = String(
|
||||||
|
// localized: "common.ok",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "OK button text"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocalizationEdgeCases() {
|
||||||
|
// // Test edge cases that might cause issues
|
||||||
|
//
|
||||||
|
// // Large numbers
|
||||||
|
// let largeNumberResult = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// 1000000
|
||||||
|
// )
|
||||||
|
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||||
|
//
|
||||||
|
// // Zero
|
||||||
|
// let zeroResult = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// 0
|
||||||
|
// )
|
||||||
|
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||||
|
//
|
||||||
|
// // Empty geohash (edge case)
|
||||||
|
// let emptyGeohashResult = String(
|
||||||
|
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// "", 1
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//// MARK: - Helpers
|
||||||
|
//
|
||||||
|
//private struct CatalogContext {
|
||||||
|
// let catalog: StringCatalog
|
||||||
|
// let locales: [String]
|
||||||
|
// let baseLocale: String
|
||||||
|
// let keysByLocale: [String: Set<String>]
|
||||||
|
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct StringCatalog: Decodable {
|
||||||
|
// let sourceLanguage: String
|
||||||
|
// let strings: [String: CatalogEntry]
|
||||||
|
//
|
||||||
|
// var locales: [String] {
|
||||||
|
// var localeSet: Set<String> = []
|
||||||
|
// for entry in strings.values {
|
||||||
|
// localeSet.formUnion(entry.localizations.keys)
|
||||||
|
// }
|
||||||
|
// return localeSet.sorted()
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogEntry: Decodable {
|
||||||
|
// let localizations: [String: CatalogLocalization]
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogLocalization: Decodable {
|
||||||
|
// let stringUnit: CatalogStringUnit?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogStringUnit: Decodable {
|
||||||
|
// let state: String
|
||||||
|
// let value: String?
|
||||||
|
// let variations: CatalogVariations?
|
||||||
|
// let comment: String?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogVariations: Decodable {
|
||||||
|
// let plural: [String: [String: CatalogVariationValue]]?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogVariationValue: Decodable {
|
||||||
|
// let stringUnit: CatalogStringUnit?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct Segment {
|
||||||
|
// let components: [String]
|
||||||
|
// let value: String
|
||||||
|
//
|
||||||
|
// var path: String {
|
||||||
|
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||||
|
// var segments: [Segment] = []
|
||||||
|
// if let value = unit.value {
|
||||||
|
// segments.append(Segment(components: prefix, value: value))
|
||||||
|
// } else if prefix.isEmpty {
|
||||||
|
// segments.append(Segment(components: [], value: ""))
|
||||||
|
// }
|
||||||
|
// if let plural = unit.variations?.plural {
|
||||||
|
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// if let nested = variation.stringUnit {
|
||||||
|
// var nextPrefix = prefix
|
||||||
|
// nextPrefix.append("plural")
|
||||||
|
// nextPrefix.append(variable)
|
||||||
|
// nextPrefix.append(category)
|
||||||
|
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return segments
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||||
|
// tokens.sorted()
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||||
|
// let parts = localePath.split(separator: ".")
|
||||||
|
// guard parts.count == 3, parts.first == "plural" else {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// let variable = parts[1]
|
||||||
|
// let otherKey = "plural.\(variable).other"
|
||||||
|
// if baseMap[otherKey] != nil {
|
||||||
|
// return otherKey
|
||||||
|
// }
|
||||||
|
// let oneKey = "plural.\(variable).one"
|
||||||
|
// if baseMap[oneKey] != nil {
|
||||||
|
// return oneKey
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private let placeholderRegex: NSRegularExpression = {
|
||||||
|
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||||
|
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||||
|
//}()
|
||||||
|
//
|
||||||
|
//private func placeholders(in string: String) -> [String] {
|
||||||
|
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||||
|
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||||
|
// var tokens: [String] = []
|
||||||
|
// for match in matches {
|
||||||
|
// if let range = Range(match.range, in: string) {
|
||||||
|
// let token = String(string[range])
|
||||||
|
// if token == "%%" { continue }
|
||||||
|
// tokens.append(token)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return tokens
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct PrimaryKeyConfig: Decodable {
|
||||||
|
// let app: [String]
|
||||||
|
// let shareExtension: [String]
|
||||||
|
// let expectedValues: [String: [String: String]]?
|
||||||
|
// let testLocales: [String]?
|
||||||
|
//}
|
||||||
@@ -1,604 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageDeduplicationServiceTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for MessageDeduplicationService, LRUDeduplicationCache, and ContentNormalizer.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - LRU Deduplication Cache Tests
|
|
||||||
|
|
||||||
@Suite("LRU Deduplication Cache")
|
|
||||||
@MainActor
|
|
||||||
struct LRUDeduplicationCacheTests {
|
|
||||||
|
|
||||||
// MARK: - Basic Operations
|
|
||||||
|
|
||||||
@Test func emptyCache_containsReturnsFalse() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
#expect(!cache.contains("key"))
|
|
||||||
#expect(cache.value(for: "key") == nil)
|
|
||||||
#expect(cache.count == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func record_addsEntry() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("key1", value: 42)
|
|
||||||
|
|
||||||
#expect(cache.contains("key1"))
|
|
||||||
#expect(cache.value(for: "key1") == 42)
|
|
||||||
#expect(cache.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func record_updatesExistingEntry() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("key1", value: 42)
|
|
||||||
cache.record("key1", value: 100)
|
|
||||||
|
|
||||||
#expect(cache.value(for: "key1") == 100)
|
|
||||||
#expect(cache.count == 1) // Should not increase count
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func record_multipleEntries() {
|
|
||||||
let cache = LRUDeduplicationCache<String>(capacity: 10)
|
|
||||||
cache.record("a", value: "alpha")
|
|
||||||
cache.record("b", value: "beta")
|
|
||||||
cache.record("c", value: "gamma")
|
|
||||||
|
|
||||||
#expect(cache.count == 3)
|
|
||||||
#expect(cache.value(for: "a") == "alpha")
|
|
||||||
#expect(cache.value(for: "b") == "beta")
|
|
||||||
#expect(cache.value(for: "c") == "gamma")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func remove_removesEntry() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("key1", value: 42)
|
|
||||||
cache.record("key2", value: 100)
|
|
||||||
|
|
||||||
cache.remove("key1")
|
|
||||||
|
|
||||||
#expect(!cache.contains("key1"))
|
|
||||||
#expect(cache.contains("key2"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clear_removesAllEntries() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
cache.record("c", value: 3)
|
|
||||||
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
#expect(cache.count == 0)
|
|
||||||
#expect(!cache.contains("a"))
|
|
||||||
#expect(!cache.contains("b"))
|
|
||||||
#expect(!cache.contains("c"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Eviction Tests
|
|
||||||
|
|
||||||
@Test func eviction_removesOldestWhenOverCapacity() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 3)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
cache.record("c", value: 3)
|
|
||||||
cache.record("d", value: 4) // Should evict "a"
|
|
||||||
|
|
||||||
#expect(cache.count == 3)
|
|
||||||
#expect(!cache.contains("a")) // Evicted
|
|
||||||
#expect(cache.contains("b"))
|
|
||||||
#expect(cache.contains("c"))
|
|
||||||
#expect(cache.contains("d"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eviction_maintainsCapacity() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 2)
|
|
||||||
|
|
||||||
for i in 0..<100 {
|
|
||||||
cache.record("key\(i)", value: i)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(cache.count == 2)
|
|
||||||
// Most recent entries should be present
|
|
||||||
#expect(cache.contains("key99"))
|
|
||||||
#expect(cache.contains("key98"))
|
|
||||||
// Older entries should be evicted
|
|
||||||
#expect(!cache.contains("key0"))
|
|
||||||
#expect(!cache.contains("key50"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eviction_capacityOfOne() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 1)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
|
|
||||||
#expect(cache.count == 1)
|
|
||||||
#expect(!cache.contains("a"))
|
|
||||||
#expect(cache.contains("b"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eviction_skipsRemovedKeys() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 3)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
cache.record("c", value: 3)
|
|
||||||
|
|
||||||
// Remove "a" manually
|
|
||||||
cache.remove("a")
|
|
||||||
|
|
||||||
// Add new entry - should evict "b" (next oldest still in map)
|
|
||||||
cache.record("d", value: 4)
|
|
||||||
|
|
||||||
// Cache should have b, c, d (a was removed)
|
|
||||||
// Actually after eviction it should have c, d and maybe b depending on implementation
|
|
||||||
#expect(!cache.contains("a"))
|
|
||||||
#expect(cache.count <= 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Edge Cases
|
|
||||||
|
|
||||||
@Test func emptyKey_works() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("", value: 42)
|
|
||||||
|
|
||||||
#expect(cache.contains(""))
|
|
||||||
#expect(cache.value(for: "") == 42)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func largeCapacity_works() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10000)
|
|
||||||
|
|
||||||
for i in 0..<5000 {
|
|
||||||
cache.record("key\(i)", value: i)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(cache.count == 5000)
|
|
||||||
#expect(cache.contains("key0"))
|
|
||||||
#expect(cache.contains("key4999"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Normalizer Tests
|
|
||||||
|
|
||||||
struct ContentNormalizerTests {
|
|
||||||
|
|
||||||
@Test func normalizedKey_basicContent() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_caseInsensitive() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("hello world")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("HELLO WORLD")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_whitespaceCollapsed() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("Hello\t\nWorld")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_trimmed() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(" Hello ")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("\nHello\n")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_urlQueryStripped() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Check https://example.com/page")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Check https://example.com/page?query=value")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("Check https://example.com/page#anchor")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_httpAndHttpsDistinct() {
|
|
||||||
// URL scheme is preserved
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("http://example.com/page")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("https://example.com/page")
|
|
||||||
#expect(key1 != key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_differentContent() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Goodbye")
|
|
||||||
#expect(key1 != key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_returnsHashFormat() {
|
|
||||||
let key = ContentNormalizer.normalizedKey("Test content")
|
|
||||||
#expect(key.hasPrefix("h:"))
|
|
||||||
#expect(key.count == 18) // "h:" + 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_emptyContent() {
|
|
||||||
let key = ContentNormalizer.normalizedKey("")
|
|
||||||
#expect(key.hasPrefix("h:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_longContentTruncated() {
|
|
||||||
let longContent = String(repeating: "a", count: 10000)
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(longContent)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(longContent + "extra")
|
|
||||||
|
|
||||||
// Both should be the same since content is truncated before hashing
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_prefixLengthRespected() {
|
|
||||||
let content = "Short"
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(content, prefixLength: 3)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(content, prefixLength: 100)
|
|
||||||
|
|
||||||
// Different prefix lengths may produce different keys
|
|
||||||
// "sho" vs "short"
|
|
||||||
#expect(key1 != key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_urlsInMiddleOfContent() {
|
|
||||||
let content1 = "Check out https://example.com/path?query=1 for more info"
|
|
||||||
let content2 = "Check out https://example.com/path for more info"
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(content1)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(content2)
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_multipleUrls() {
|
|
||||||
let content1 = "Links: https://a.com?x=1 and http://b.com#y"
|
|
||||||
let content2 = "Links: https://a.com and http://b.com"
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(content1)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(content2)
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Deduplication Service Tests
|
|
||||||
|
|
||||||
@Suite("Message Deduplication Service")
|
|
||||||
@MainActor
|
|
||||||
struct MessageDeduplicationServiceTests {
|
|
||||||
|
|
||||||
// MARK: - Content Deduplication
|
|
||||||
|
|
||||||
@Test func recordContent_storesTimestamp() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("Hello World", timestamp: now)
|
|
||||||
|
|
||||||
let retrieved = service.contentTimestamp(for: "Hello World")
|
|
||||||
#expect(retrieved == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordContent_updatesTimestamp() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let early = Date(timeIntervalSince1970: 1000)
|
|
||||||
let late = Date(timeIntervalSince1970: 2000)
|
|
||||||
|
|
||||||
service.recordContent("Hello World", timestamp: early)
|
|
||||||
service.recordContent("Hello World", timestamp: late)
|
|
||||||
|
|
||||||
let retrieved = service.contentTimestamp(for: "Hello World")
|
|
||||||
#expect(retrieved == late)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func contentTimestamp_nilForUnseen() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
let timestamp = service.contentTimestamp(for: "Never seen")
|
|
||||||
#expect(timestamp == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordContentKey_directKeyAccess() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
let key = service.normalizedContentKey("Test")
|
|
||||||
|
|
||||||
service.recordContentKey(key, timestamp: now)
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(forKey: key) == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedContentKey_consistentWithNormalizer() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let content = "Hello World"
|
|
||||||
|
|
||||||
let serviceKey = service.normalizedContentKey(content)
|
|
||||||
let normalizerKey = ContentNormalizer.normalizedKey(content)
|
|
||||||
|
|
||||||
#expect(serviceKey == normalizerKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Event Deduplication
|
|
||||||
|
|
||||||
@Test func recordNostrEvent_marksAsProcessed() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event123"))
|
|
||||||
|
|
||||||
service.recordNostrEvent("event123")
|
|
||||||
|
|
||||||
#expect(service.hasProcessedNostrEvent("event123"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasProcessedNostrEvent_falseForUnseen() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
#expect(!service.hasProcessedNostrEvent("never-seen"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nostrEvent_multipleEvents() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
service.recordNostrEvent("event1")
|
|
||||||
service.recordNostrEvent("event2")
|
|
||||||
service.recordNostrEvent("event3")
|
|
||||||
|
|
||||||
#expect(service.hasProcessedNostrEvent("event1"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("event2"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("event3"))
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event4"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr ACK Deduplication
|
|
||||||
|
|
||||||
@Test func recordNostrAck_marksAsProcessed() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let ackKey = MessageDeduplicationService.ackKey(
|
|
||||||
messageId: "msg123",
|
|
||||||
ackType: "delivered",
|
|
||||||
senderPubkey: "pubkey456"
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(!service.hasProcessedNostrAck(ackKey))
|
|
||||||
|
|
||||||
service.recordNostrAck(ackKey)
|
|
||||||
|
|
||||||
#expect(service.hasProcessedNostrAck(ackKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func ackKey_format() {
|
|
||||||
let key = MessageDeduplicationService.ackKey(
|
|
||||||
messageId: "msg",
|
|
||||||
ackType: "read",
|
|
||||||
senderPubkey: "pub"
|
|
||||||
)
|
|
||||||
#expect(key == "msg:read:pub")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func ackKey_differentComponents() {
|
|
||||||
let key1 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "delivered", senderPubkey: "x")
|
|
||||||
let key2 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "read", senderPubkey: "x")
|
|
||||||
let key3 = MessageDeduplicationService.ackKey(messageId: "b", ackType: "delivered", senderPubkey: "x")
|
|
||||||
|
|
||||||
#expect(key1 != key2) // Different ackType
|
|
||||||
#expect(key1 != key3) // Different messageId
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear Operations
|
|
||||||
|
|
||||||
@Test func clearAll_clearsEverything() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("Hello", timestamp: now)
|
|
||||||
service.recordNostrEvent("event1")
|
|
||||||
service.recordNostrAck("ack1")
|
|
||||||
|
|
||||||
service.clearAll()
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(for: "Hello") == nil)
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event1"))
|
|
||||||
#expect(!service.hasProcessedNostrAck("ack1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clearNostrCaches_preservesContent() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("Hello", timestamp: now)
|
|
||||||
service.recordNostrEvent("event1")
|
|
||||||
service.recordNostrAck("ack1")
|
|
||||||
|
|
||||||
service.clearNostrCaches()
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(for: "Hello") == now) // Preserved
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event1")) // Cleared
|
|
||||||
#expect(!service.hasProcessedNostrAck("ack1")) // Cleared
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Capacity Tests
|
|
||||||
|
|
||||||
@Test func contentCache_respectsCapacity() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 3, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
service.recordContent("a", timestamp: Date())
|
|
||||||
service.recordContent("b", timestamp: Date())
|
|
||||||
service.recordContent("c", timestamp: Date())
|
|
||||||
service.recordContent("d", timestamp: Date())
|
|
||||||
|
|
||||||
// "a" should have been evicted
|
|
||||||
#expect(service.contentTimestamp(for: "a") == nil)
|
|
||||||
#expect(service.contentTimestamp(for: "d") != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nostrEventCache_respectsCapacity() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 3)
|
|
||||||
|
|
||||||
service.recordNostrEvent("e1")
|
|
||||||
service.recordNostrEvent("e2")
|
|
||||||
service.recordNostrEvent("e3")
|
|
||||||
service.recordNostrEvent("e4")
|
|
||||||
|
|
||||||
// "e1" should have been evicted
|
|
||||||
#expect(!service.hasProcessedNostrEvent("e1"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("e4"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Integration Tests
|
|
||||||
|
|
||||||
@Test func realWorldDeduplication_similarMessages() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
// Record original message
|
|
||||||
service.recordContent("Check out https://example.com/page?ref=abc", timestamp: now)
|
|
||||||
|
|
||||||
// Same URL with different query params should match
|
|
||||||
let timestamp = service.contentTimestamp(for: "Check out https://example.com/page?ref=xyz")
|
|
||||||
#expect(timestamp == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func realWorldDeduplication_caseVariations() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("HELLO WORLD", timestamp: now)
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(for: "hello world") == now)
|
|
||||||
#expect(service.contentTimestamp(for: "Hello World") == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Thread Safety Tests (via @MainActor enforcement)
|
|
||||||
|
|
||||||
@Test("Concurrent content recording is safe via MainActor")
|
|
||||||
func concurrentContentRecording() async {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
|
|
||||||
let iterations = 100
|
|
||||||
|
|
||||||
// All operations run on MainActor due to @MainActor annotation
|
|
||||||
// This test verifies the pattern works correctly
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
service.recordContent("Message \(i)", timestamp: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify some entries were recorded
|
|
||||||
#expect(service.contentTimestamp(for: "Message 0") != nil)
|
|
||||||
#expect(service.contentTimestamp(for: "Message 99") != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Concurrent Nostr event recording is safe via MainActor")
|
|
||||||
func concurrentNostrEventRecording() async {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
|
|
||||||
let iterations = 100
|
|
||||||
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
service.recordNostrEvent("event_\(i)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify events were recorded
|
|
||||||
#expect(service.hasProcessedNostrEvent("event_0"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("event_99"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Mixed concurrent operations are safe via MainActor")
|
|
||||||
func concurrentMixedOperations() async {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
|
|
||||||
let iterations = 50
|
|
||||||
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
// Content recording tasks
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
service.recordContent("Content \(i)", timestamp: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event recording tasks
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
service.recordNostrEvent("event_\(i)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACK recording tasks
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
service.recordNostrAck("ack_\(i)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read tasks
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
_ = service.contentTimestamp(for: "Content \(i)")
|
|
||||||
_ = service.hasProcessedNostrEvent("event_\(i)")
|
|
||||||
_ = service.hasProcessedNostrAck("ack_\(i)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach here without crashes, the test passes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - LRU Cache Thread Safety Tests
|
|
||||||
|
|
||||||
@Suite("LRU Cache Thread Safety")
|
|
||||||
@MainActor
|
|
||||||
struct LRUCacheThreadSafetyTests {
|
|
||||||
|
|
||||||
@Test("Concurrent cache access is safe via MainActor")
|
|
||||||
func concurrentCacheAccess() async {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 500)
|
|
||||||
let iterations = 100
|
|
||||||
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
// Write tasks
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
cache.record("key_\(i)", value: i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read tasks
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
_ = cache.contains("key_\(i)")
|
|
||||||
_ = cache.value(for: "key_\(i)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify cache is in consistent state
|
|
||||||
#expect(cache.count <= 500) // Respects capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Cache eviction under concurrent load is safe")
|
|
||||||
func cacheEvictionUnderLoad() async {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
let iterations = 100
|
|
||||||
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
for i in 0..<iterations {
|
|
||||||
group.addTask { @MainActor in
|
|
||||||
cache.record("key_\(i)", value: i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache should maintain its capacity constraint
|
|
||||||
#expect(cache.count == 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageFormattingEngineTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for MessageFormattingEngine regex patterns and utility functions.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct MessageFormattingEngineTests {
|
|
||||||
|
|
||||||
// MARK: - Mention Extraction Tests
|
|
||||||
|
|
||||||
@Test func extractMentions_singleMention() {
|
|
||||||
let content = "Hello @alice how are you?"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions == ["alice"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_multipleMentions() {
|
|
||||||
let content = "@alice and @bob are chatting with @charlie"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions.count == 3)
|
|
||||||
#expect(mentions.contains("alice"))
|
|
||||||
#expect(mentions.contains("bob"))
|
|
||||||
#expect(mentions.contains("charlie"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_mentionWithSuffix() {
|
|
||||||
let content = "Hey @alice#a1b2 check this out"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions == ["alice#a1b2"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_noMentions() {
|
|
||||||
let content = "Just a regular message with no mentions"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_unicodeNickname() {
|
|
||||||
let content = "Hello @日本語 and @émile"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions.count == 2)
|
|
||||||
#expect(mentions.contains("日本語"))
|
|
||||||
#expect(mentions.contains("émile"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_mentionWithUnderscore() {
|
|
||||||
let content = "Thanks @user_name_123"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions == ["user_name_123"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_emailNotCaptured() {
|
|
||||||
// Email addresses should not be captured as mentions
|
|
||||||
let content = "Contact me at test@example.com"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
// The regex will capture "example" after @ in email - this is expected behavior
|
|
||||||
// as the regex doesn't distinguish email addresses
|
|
||||||
#expect(mentions.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cashu Token Detection Tests
|
|
||||||
|
|
||||||
@Test func containsCashuToken_validTokenA() {
|
|
||||||
let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
|
|
||||||
#expect(MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func containsCashuToken_validTokenB() {
|
|
||||||
let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
|
|
||||||
#expect(MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func containsCashuToken_noToken() {
|
|
||||||
let content = "Just a regular message about cashews"
|
|
||||||
#expect(!MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func containsCashuToken_tooShort() {
|
|
||||||
let content = "Invalid: cashuAshort"
|
|
||||||
#expect(!MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Regex Pattern Tests
|
|
||||||
|
|
||||||
@Test func hashtagPattern_standaloneHashtag() {
|
|
||||||
let content = "#bitcoin is great"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hashtagPattern_multipleHashtags() {
|
|
||||||
let content = "#bitcoin #lightning #nostr"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hashtagPattern_hashInMiddleOfWord() {
|
|
||||||
let content = "test#notahashtag"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
|
||||||
// This will match because the regex doesn't check for word boundaries
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func bolt11Pattern_mainnet() {
|
|
||||||
let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func bolt11Pattern_testnet() {
|
|
||||||
let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func lnurlPattern_valid() {
|
|
||||||
let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func lightningSchemePattern_valid() {
|
|
||||||
let content = "Click: lightning:lnbc10u1example"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func cashuPattern_valid() {
|
|
||||||
let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0="
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - URL Detection Tests
|
|
||||||
|
|
||||||
@Test func linkDetector_httpURL() {
|
|
||||||
let content = "Check out http://example.com"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func linkDetector_httpsURL() {
|
|
||||||
let content = "Visit https://example.com/path?query=value"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func linkDetector_multipleURLs() {
|
|
||||||
let content = "See https://a.com and http://b.com"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
|
||||||
#expect(matches.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - String Extension Tests
|
|
||||||
|
|
||||||
@Test func splitSuffix_withSuffix() {
|
|
||||||
let name = "alice#a1b2"
|
|
||||||
let (base, suffix) = name.splitSuffix()
|
|
||||||
#expect(base == "alice")
|
|
||||||
#expect(suffix == "#a1b2")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func splitSuffix_withoutSuffix() {
|
|
||||||
let name = "alice"
|
|
||||||
let (base, suffix) = name.splitSuffix()
|
|
||||||
#expect(base == "alice")
|
|
||||||
#expect(suffix == "")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func splitSuffix_withAtPrefix() {
|
|
||||||
let name = "@alice#a1b2"
|
|
||||||
let (base, suffix) = name.splitSuffix()
|
|
||||||
#expect(base == "alice")
|
|
||||||
#expect(suffix == "#a1b2")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasVeryLongToken_noLongToken() {
|
|
||||||
let content = "Short words only here"
|
|
||||||
#expect(!content.hasVeryLongToken(threshold: 50))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasVeryLongToken_withLongToken() {
|
|
||||||
let longToken = String(repeating: "a", count: 100)
|
|
||||||
let content = "Here is a \(longToken) token"
|
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasVeryLongToken_exactThreshold() {
|
|
||||||
let exactToken = String(repeating: "a", count: 50)
|
|
||||||
let content = "Token: \(exactToken)"
|
|
||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// MimeTypeTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - MimeType Mapping and Signature Tests
|
|
||||||
|
|
||||||
struct MimeTypeTests {
|
|
||||||
|
|
||||||
// MARK: MIME → Enum Parsing + Default Extension
|
|
||||||
@Test(arguments: [
|
|
||||||
("image/jpeg", MimeType.jpeg, "jpg"),
|
|
||||||
("image/jpg", MimeType.jpeg, "jpg"),
|
|
||||||
("image/png", MimeType.png, "png"),
|
|
||||||
("image/gif", MimeType.gif, "gif"),
|
|
||||||
("image/webp", MimeType.webp, "webp"),
|
|
||||||
("audio/mp4", MimeType.mp4Audio, "m4a"),
|
|
||||||
("audio/m4a", MimeType.m4a, "m4a"),
|
|
||||||
("audio/aac", MimeType.aac, "m4a"),
|
|
||||||
("audio/mpeg", MimeType.mpeg, "mp3"),
|
|
||||||
("audio/mp3", MimeType.mp3, "mp3"),
|
|
||||||
("audio/wav", MimeType.wav, "wav"),
|
|
||||||
("audio/x-wav", MimeType.xWav, "wav"),
|
|
||||||
("audio/ogg", MimeType.ogg, "ogg"),
|
|
||||||
("application/pdf", MimeType.pdf, "pdf"),
|
|
||||||
("application/octet-stream", MimeType.octetStream, "bin")
|
|
||||||
])
|
|
||||||
func mimeTypeParsingAndExtensions(
|
|
||||||
mimeString: String,
|
|
||||||
expectedType: MimeType,
|
|
||||||
expectedExt: String
|
|
||||||
) throws {
|
|
||||||
guard let mime = MimeType(mimeString) else {
|
|
||||||
Issue.record("Failed to parse \(mimeString)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
|
|
||||||
#expect(mime.mimeString == expectedType.mimeString)
|
|
||||||
#expect(mime.defaultExtension == expectedExt)
|
|
||||||
#expect(mime.isAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - File Signature Validation
|
|
||||||
@Test(arguments: [
|
|
||||||
// === Image types ===
|
|
||||||
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
|
|
||||||
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
||||||
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
|
|
||||||
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
|
|
||||||
|
|
||||||
// === Audio types ===
|
|
||||||
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
|
|
||||||
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
|
|
||||||
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
|
|
||||||
|
|
||||||
// === Application types ===
|
|
||||||
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
|
|
||||||
])
|
|
||||||
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
|
|
||||||
let data = Data(bytes)
|
|
||||||
#expect(mime.matches(data: data),
|
|
||||||
"Expected \(mime.mimeString) to match its signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Negative Tests
|
|
||||||
@Test func invalidDataDoesNotMatch() throws {
|
|
||||||
let badData = Data(repeating: 0x00, count: 16)
|
|
||||||
for mime in MimeType.allCases where mime != .octetStream {
|
|
||||||
#expect(!mime.matches(data: badData),
|
|
||||||
"Unexpectedly matched \(mime.mimeString) with zeroed data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Octet-stream (generic binary)
|
|
||||||
@Test func octetStreamAlwaysMatches() throws {
|
|
||||||
let randomData = Data([0x00, 0x11, 0x22, 0x33])
|
|
||||||
#expect(MimeType.octetStream.matches(data: randomData),
|
|
||||||
"application/octet-stream should always be considered valid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user