mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1207ac2af5 | ||
|
|
826c7537bf | ||
|
|
76dbc98e5b | ||
|
|
7f7ea05fbc | ||
|
|
c1430eaeb9 | ||
|
|
d76472999d | ||
|
|
f9a218d68b | ||
|
|
8eb4fb60e6 | ||
|
|
ca9748ede0 | ||
|
|
946abce1b2 | ||
|
|
326ff628f7 | ||
|
|
2040e94b83 | ||
|
|
8f62dd1776 | ||
|
|
7722009f11 | ||
|
|
da0474680c | ||
|
|
0a525be57a | ||
|
|
16e9271570 | ||
|
|
747551f35a | ||
|
|
367addf138 | ||
|
|
aa35200c6f | ||
|
|
e09de446fc | ||
|
|
0714b09a89 | ||
|
|
adb2626898 | ||
|
|
20435d55e8 | ||
|
|
a0187fb430 | ||
|
|
9c55a2e1fd | ||
|
|
eb37aa8046 | ||
|
|
2edf29033f | ||
|
|
b6cb287991 | ||
|
|
c9be273750 | ||
|
|
c7280284ea | ||
|
|
24cc307a0e | ||
|
|
177642ac4d | ||
|
|
ef6309c08f | ||
|
|
7ab7fbfd1b | ||
|
|
2b5505a20d | ||
|
|
ccce384a90 | ||
|
|
e2da5e2ef9 | ||
|
|
a71b8cd545 | ||
|
|
1d4bf96f7a | ||
|
|
c55c19e738 | ||
|
|
5cafa4d5b4 | ||
|
|
567e1dbbbf | ||
|
|
9e0542df73 | ||
|
|
32a8e558ed | ||
|
|
5209a6cfcf | ||
|
|
d290fd4670 | ||
|
|
cb53a3b48e | ||
|
|
8001486a2b | ||
|
|
0d6c1a0b44 | ||
|
|
d8e8703a5f | ||
|
|
c75f32da2c | ||
|
|
dcd26c19d7 | ||
|
|
eccec2f27d | ||
|
|
de7a496af9 | ||
|
|
ee19d9c948 | ||
|
|
74414c369a | ||
|
|
7935857dae | ||
|
|
d3e32bdbee | ||
|
|
77aaa3c0d1 | ||
|
|
6183501285 | ||
|
|
89e20738c8 | ||
|
|
84c89d38d3 | ||
|
|
4ec6590b23 | ||
|
|
b619c4259d | ||
|
|
f7859f7b04 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 | ||
|
|
f5caa1751a | ||
|
|
de906cb97c | ||
|
|
a41ec65f58 |
@@ -24,4 +24,4 @@ jobs:
|
||||
run: swift build
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
||||
run: swift test --parallel
|
||||
|
||||
+5
-4
@@ -9,9 +9,6 @@ plans/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/xcshareddata/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
@@ -68,6 +66,9 @@ __pycache__/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
## Cache
|
||||
.cache/
|
||||
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.2
|
||||
MARKETING_VERSION = 1.5.0
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+9
-14
@@ -4,6 +4,7 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "bitchat",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
@@ -15,6 +16,7 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Tor"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
@@ -24,8 +26,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.target(name: "TorC"),
|
||||
.target(name: "tor-nolzma")
|
||||
.product(name: "Tor", package: "Tor")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
@@ -33,21 +34,12 @@ let package = Package(
|
||||
"Assets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
"Services/Tor/C/"
|
||||
"LaunchScreen.storyboard"
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("z")
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "TorC",
|
||||
path: "bitchat/Services/Tor/C"
|
||||
),
|
||||
.binaryTarget(
|
||||
name: "tor-nolzma",
|
||||
path: "Frameworks/tor-nolzma.xcframework"
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: ["bitchat"],
|
||||
@@ -55,6 +47,9 @@ let package = Package(
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -116,3 +116,10 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatPacket"
|
||||
---
|
||||
packet
|
||||
+8: "Version"
|
||||
+8: "Type"
|
||||
+8: "TTL"
|
||||
+64: "Timestamp"
|
||||
+8: "Flags"
|
||||
+16: "Payload Length"
|
||||
+64: "Sender ID"
|
||||
+64: "Recipient ID (optional)"
|
||||
+48: "Payload (variable)"
|
||||
+64: "Signature (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatMessage"
|
||||
---
|
||||
packet
|
||||
+8: "Flags"
|
||||
+64: "Timestamp"
|
||||
+24: "ID (variable)"
|
||||
+32: "Sender (variable)"
|
||||
+32: "Content (variable)"
|
||||
+32: "Original Sender (variable) (optional)"
|
||||
+32: "Recipient Nickname (variable) (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
Generated
+45
-21
@@ -7,15 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* 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 */; };
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -58,11 +57,10 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -87,7 +85,6 @@
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
LaunchScreen.storyboard,
|
||||
Services/Tor/C/include/.gitkeep,
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
@@ -136,32 +133,22 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
||||
);
|
||||
path = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
18198ED912AAF495D8AF7763 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -170,8 +157,8 @@
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||
A6E367C92E76469E0032EA8A /* Configs */,
|
||||
0481A3542E6D877600FC845E /* Frameworks */,
|
||||
9F37F9F2C353B58AC809E93B /* Products */,
|
||||
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -187,6 +174,14 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -207,6 +202,7 @@
|
||||
packageProductDependencies = (
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -284,6 +280,7 @@
|
||||
packageProductDependencies = (
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -304,12 +301,27 @@
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
es,
|
||||
ar,
|
||||
de,
|
||||
fr,
|
||||
he,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ne,
|
||||
"pt-BR",
|
||||
ru,
|
||||
tr,
|
||||
uk,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = 18198ED912AAF495D8AF7763;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -867,6 +879,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -894,6 +910,14 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
};
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||
BuildableName = "bitchatShareExtension.appex"
|
||||
BlueprintName = "bitchatShareExtension"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||
BuildableName = "bitchatTests_iOS.xctest"
|
||||
BlueprintName = "bitchatTests_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||
BuildableName = "bitchatTests_macOS.xctest"
|
||||
BlueprintName = "bitchatTests_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -13,7 +13,6 @@
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@@ -25,7 +24,6 @@
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@@ -25,11 +26,15 @@ struct BitchatApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
init() {
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
wrappedValue: ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
@@ -49,7 +54,7 @@ struct BitchatApp: App {
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
private static let targetImageBytes: Int = 60_000
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
// Allow up to 10MB source images (will be scaled down)
|
||||
guard fileSize <= 10 * 1024 * 1024 else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension)
|
||||
#else
|
||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL()
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL()
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||
let scaledImage = NSImage(size: newSize)
|
||||
scaledImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||
from: NSRect(origin: .zero, size: size),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||
|
||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func applicationFilesDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
|
||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published private(set) var isPlaying: Bool = false
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
|
||||
func loadDuration() {
|
||||
guard duration == 0 else { return }
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||
let loadedDuration = player.duration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, self.duration == 0 else { return }
|
||||
self.duration = loadedDuration
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
guard url != self.url else { return }
|
||||
stop()
|
||||
self.url = url
|
||||
player = nil
|
||||
duration = 0
|
||||
// Duration will be loaded on demand when needed
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
player?.play()
|
||||
startTimer()
|
||||
updateProgress()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player?.currentTime = 0
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
guard ensurePlayerReady() else { return }
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
} catch {
|
||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
player = nil
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePlayerReady() -> Bool {
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
return player != nil
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
self?.updateProgress()
|
||||
}
|
||||
if let timer = timer {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
guard let player = player else {
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
progress = 0
|
||||
return
|
||||
}
|
||||
currentTime = player.currentTime
|
||||
duration = player.duration
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one voice note plays at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
private weak var activeController: VoiceNotePlaybackController?
|
||||
|
||||
private init() {}
|
||||
|
||||
func activate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
return
|
||||
}
|
||||
activeController?.pause()
|
||||
activeController = controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
case recordingInProgress
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var stopWorkItem: DispatchWorkItem?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@discardableResult
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
func startRecording() throws -> URL {
|
||||
try queue.sync {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 20_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record()
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
stopWorkItem?.cancel()
|
||||
stopWorkItem = nil
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||
completion(self?.currentURL)
|
||||
return
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
recorder.stop()
|
||||
self.cleanupSession()
|
||||
let url = self.currentURL
|
||||
self.recorder = nil
|
||||
self.currentURL = url
|
||||
completion(url)
|
||||
}
|
||||
self.stopWorkItem = item
|
||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopWorkItem?.cancel()
|
||||
self.stopWorkItem = nil
|
||||
if let recorder = self.recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
self.cleanupSession()
|
||||
if let url = self.currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
self.recorder = nil
|
||||
self.currentURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
func currentAveragePower() -> Float {
|
||||
queue.sync {
|
||||
recorder?.updateMeters()
|
||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||
|
||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
#if os(iOS)
|
||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
#else
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func cleanupSession() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||
final class WaveformCache {
|
||||
static let shared = WaveformCache()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||
|
||||
private init() {}
|
||||
|
||||
func cachedWaveform(for url: URL) -> [Float]? {
|
||||
queue.sync {
|
||||
guard let entry = cache[url] else { return nil }
|
||||
return entry.waveform
|
||||
}
|
||||
}
|
||||
|
||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check cache (read-only, no update needed on cache hit for performance)
|
||||
if let entry = self.cache[url] {
|
||||
DispatchQueue.main.async { completion(entry.waveform) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Evict oldest entry if cache is full
|
||||
if self.cache.count >= self.maxCacheSize {
|
||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
self.cache.removeValue(forKey: oldest.key)
|
||||
}
|
||||
}
|
||||
|
||||
self.cache[url] = (computed, Date())
|
||||
}
|
||||
DispatchQueue.main.async { completion(computed) }
|
||||
}
|
||||
}
|
||||
|
||||
func purge(url: URL) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeValue(forKey: url)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeAll() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
return autoreleasepool {
|
||||
do {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let length = Int(audioFile.length)
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||
return nil
|
||||
}
|
||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||
guard let channelData = buffer.floatChannelData else { return nil }
|
||||
|
||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
let samplesPerBin = max(1, frameLength / bins)
|
||||
|
||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||
for bin in 0..<bins {
|
||||
let start = bin * samplesPerBin
|
||||
let end = min(frameLength, start + samplesPerBin)
|
||||
if start >= end { break }
|
||||
|
||||
var sum: Float = 0
|
||||
var sampleCount = 0
|
||||
for frame in start..<end {
|
||||
var sampleValue: Float = 0
|
||||
for channel in 0..<channelCount {
|
||||
sampleValue += fabsf(channelData[channel][frame])
|
||||
}
|
||||
sum += sampleValue / Float(channelCount)
|
||||
sampleCount += 1
|
||||
}
|
||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||
}
|
||||
|
||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||
}
|
||||
return magnitudes
|
||||
} catch {
|
||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ import Foundation
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: String // 8 random bytes
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: Cryptographic Identities
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func getBlockedNostrPubkeys() -> Set<String>
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: String)
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
|
||||
// MARK: Verification
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: String) {
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
||||
let originalSender: String?
|
||||
let isPrivate: Bool
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: String?
|
||||
let senderPeerID: PeerID?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
|
||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||
}
|
||||
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||
init(
|
||||
id: String? = nil,
|
||||
sender: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
isRelay: Bool,
|
||||
originalSender: String? = nil,
|
||||
isPrivate: Bool = false,
|
||||
recipientNickname: String? = nil,
|
||||
senderPeerID: PeerID? = nil,
|
||||
mentions: [String]? = nil,
|
||||
deliveryStatus: DeliveryStatus? = nil
|
||||
) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
@@ -151,7 +163,7 @@ extension BitchatMessage {
|
||||
data.append(recipData.prefix(255))
|
||||
}
|
||||
|
||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
||||
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||
data.append(UInt8(min(peerData.count, 255)))
|
||||
data.append(peerData.prefix(255))
|
||||
}
|
||||
@@ -264,11 +276,11 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
var senderPeerID: String?
|
||||
var senderPeerID: PeerID?
|
||||
if hasSenderPeerID && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||
self.version = 1
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
self.recipientID = recipientID
|
||||
@@ -34,12 +34,12 @@ struct BitchatPacket: Codable {
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
var senderData = Data()
|
||||
var tempID = senderID
|
||||
var tempID = senderID.id
|
||||
while tempID.count >= 2 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
@@ -80,7 +80,8 @@ struct BitchatPacket: Codable {
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
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
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Identifiable, Equatable {
|
||||
let id: String // Hex-encoded peer ID
|
||||
struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Initialize from mesh service data
|
||||
init(
|
||||
id: String,
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
lhs.peerID == rhs.peerID
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// PeerID.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PeerID: Equatable, Hashable {
|
||||
enum Prefix: String, CaseIterable {
|
||||
/// When no prefix is provided
|
||||
case empty = ""
|
||||
/// `"mesh:"`
|
||||
case mesh = "mesh:"
|
||||
/// `"name:"`
|
||||
case name = "name:"
|
||||
/// `"noise:"` (+ 64 characters hex)
|
||||
case noise = "noise:"
|
||||
/// `"nostr_"` (+ 16 characters hex)
|
||||
case geoDM = "nostr_"
|
||||
/// `"nostr:"` (+ 8 characters hex)
|
||||
case geoChat = "nostr:"
|
||||
}
|
||||
|
||||
let prefix: Prefix
|
||||
|
||||
/// Returns the actual value without any prefix
|
||||
let bare: String
|
||||
|
||||
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||
var id: String { prefix.rawValue + bare }
|
||||
|
||||
// Private so the callers have to go through a convenience init
|
||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||
self.prefix = prefix
|
||||
self.bare = String(bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Inits
|
||||
|
||||
extension PeerID {
|
||||
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||
init(nostr_ pubKey: String) {
|
||||
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
}
|
||||
|
||||
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||
init(nostr pubKey: String) {
|
||||
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
}
|
||||
|
||||
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||
init(str: any StringProtocol) {
|
||||
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||
} else {
|
||||
self.init(prefix: .empty, bare: str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience init to handle `Optional<String>`
|
||||
init?(str: (any StringProtocol)?) {
|
||||
guard let str else { return nil }
|
||||
self.init(str: str)
|
||||
}
|
||||
|
||||
/// Convenience init to create PeerID by converting Data to String
|
||||
init?(data: Data) {
|
||||
self.init(str: String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init(hexData: Data) {
|
||||
self.init(str: hexData.hexEncodedString())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
|
||||
extension PeerID {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
init(publicKey: Data) {
|
||||
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||
}
|
||||
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
func toShort() -> PeerID {
|
||||
if let noiseKey {
|
||||
return PeerID(publicKey: noiseKey)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
extension PeerID: Codable {
|
||||
init(from decoder: any Decoder) throws {
|
||||
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension PeerID {
|
||||
var isEmpty: Bool {
|
||||
id.isEmpty
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`nostr:`"
|
||||
var isGeoChat: Bool {
|
||||
prefix == .geoChat
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`nostr_`"
|
||||
var isGeoDM: Bool {
|
||||
prefix == .geoDM
|
||||
}
|
||||
|
||||
func toPercentEncoded() -> String {
|
||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
extension PeerID {
|
||||
private enum Constants {
|
||||
static let maxIDLength = 64
|
||||
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
var isValid: Bool {
|
||||
if prefix != .empty {
|
||||
return PeerID(str: bare).isValid
|
||||
}
|
||||
|
||||
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||
if isShort || isNoiseKeyHex {
|
||||
return true
|
||||
}
|
||||
|
||||
// If length equals short or full but isn't valid hex, reject
|
||||
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !id.isEmpty &&
|
||||
id.count < Constants.maxIDLength &&
|
||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
/// Returns true if the `bare` id is all hex
|
||||
var isHex: Bool {
|
||||
bare.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
/// Short routing IDs (exact 16-hex)
|
||||
var isShort: Bool {
|
||||
bare.count == Constants.hexIDLength && isHex
|
||||
}
|
||||
|
||||
/// Full Noise key hex (exact 64-hex)
|
||||
var isNoiseKeyHex: Bool {
|
||||
noiseKey != nil
|
||||
}
|
||||
|
||||
/// Full Noise key (exact 64-hex) as Data
|
||||
var noiseKey: Data? {
|
||||
guard bare.count == Constants.maxIDLength else { return nil }
|
||||
return Data(hexString: bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
extension PeerID: Comparable {
|
||||
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||
lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Interop Helpers
|
||||
|
||||
// MARK: CustomStringConvertible
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
var description: String {
|
||||
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 }
|
||||
}
|
||||
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||
guard PeerID(str: readerID).isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
||||
if nameData.count <= 32 {
|
||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||
} else {
|
||||
self.hash = Data(SHA256.hash(data: nameData))
|
||||
self.hash = nameData.sha256Hash()
|
||||
}
|
||||
self.chainingKey = self.hash
|
||||
}
|
||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
|
||||
func mixHash(_ data: Data) {
|
||||
hash = Data(SHA256.hash(data: hash + data))
|
||||
hash = (hash + data).sha256Hash()
|
||||
}
|
||||
|
||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
@@ -53,11 +52,6 @@ struct NoiseSecurityValidator {
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
@@ -137,8 +131,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
@@ -146,7 +140,7 @@ final class NoiseRateLimiter {
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
@@ -175,7 +169,7 @@ final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
@@ -204,7 +198,7 @@ final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
|
||||
@@ -10,32 +10,8 @@ import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Noise Session State
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.uninitialized, .uninitialized),
|
||||
(.handshaking, .handshaking),
|
||||
(.established, .established):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true // We don't compare the errors
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Session
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var state: NoiseSessionState = .uninitialized
|
||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||
|
||||
init(
|
||||
peerID: String,
|
||||
peerID: PeerID,
|
||||
role: NoiseRole,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
return nil
|
||||
} else {
|
||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -252,249 +228,8 @@ class NoiseSession {
|
||||
handshakeHash = nil
|
||||
|
||||
if wasEstablished {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((String, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: String) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: String) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: String) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: String) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// NoiseSessionError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// NoiseSessionManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// NoiseSessionState.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData).id
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -1,50 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Keychain helper for secure storage
|
||||
struct KeychainHelper {
|
||||
static 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)
|
||||
}
|
||||
|
||||
static 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
|
||||
}
|
||||
|
||||
static 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
struct NostrIdentity: Codable {
|
||||
@@ -103,269 +58,3 @@ struct NostrIdentity: Codable {
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
private static let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private static var deviceSeedCache: Data?
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
|
||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
final class NostrIdentityBridge {
|
||||
private let keychainService = "chat.bitchat.nostr"
|
||||
private let currentIdentityKey = "nostr-current-identity"
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = keychain.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
||||
] as [Any]
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let hash = CryptoKit.SHA256.hash(data: data)
|
||||
let hashData = Data(hash)
|
||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
return (hashHex, hashData)
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
func jsonString() throws -> String {
|
||||
|
||||
@@ -2,6 +2,7 @@ import BitLogger
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Manages WebSocket connections to Nostr relays
|
||||
@MainActor
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Hex Encoding/Decoding
|
||||
|
||||
@@ -16,6 +17,11 @@ extension Data {
|
||||
}
|
||||
return self.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func sha256Hex() -> String {
|
||||
let digest = SHA256.hash(data: self)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
init?(hexString: String) {
|
||||
let len = hexString.count / 2
|
||||
@@ -197,7 +203,7 @@ extension Data {
|
||||
offset += 16
|
||||
|
||||
// Convert 16 bytes to UUID string format
|
||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
||||
let uuid = uuidData.hexEncodedString()
|
||||
|
||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||
var result = ""
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
||||
/// +--------+------+-----+-----------+-------+------------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
||||
/// +--------+------+-----+-----------+-------+------------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
@@ -52,7 +52,7 @@
|
||||
/// ## Flag Bits
|
||||
/// - Bit 0: Has recipient ID (directed message)
|
||||
/// - Bit 1: Has signature (authenticated message)
|
||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||
/// - Bit 2: Is compressed (zlib compression applied)
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
@@ -89,6 +89,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
extension Data {
|
||||
func trimmingNullBytes() -> Data {
|
||||
@@ -105,10 +106,32 @@ extension Data {
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 13
|
||||
static let v1HeaderSize = 14
|
||||
static let v2HeaderSize = 16
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
// Field offsets within packet header
|
||||
struct Offsets {
|
||||
static let version = 0
|
||||
static let type = 1
|
||||
static let ttl = 2
|
||||
static let timestamp = 3
|
||||
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||
}
|
||||
|
||||
static func headerSize(for version: UInt8) -> Int? {
|
||||
switch version {
|
||||
case 1: return v1HeaderSize
|
||||
case 2: return v2HeaderSize
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||
return version == 2 ? 4 : 2
|
||||
}
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
@@ -118,70 +141,69 @@ struct BinaryProtocol {
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
var data = Data()
|
||||
|
||||
|
||||
// Try to compress payload if beneficial
|
||||
let version = packet.version
|
||||
guard version == 1 || version == 2 else { return nil }
|
||||
|
||||
// Try to compress payload when beneficial, keeping original size for later decoding
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: UInt16? = nil
|
||||
var isCompressed = false
|
||||
|
||||
var originalPayloadSize: Int?
|
||||
if CompressionUtil.shouldCompress(payload) {
|
||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
||||
// Store original size for decompression (2 bytes after payload)
|
||||
originalPayloadSize = UInt16(payload.count)
|
||||
// Only compress when we can represent the original length in the outbound frame
|
||||
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
||||
if payload.count <= maxRepresentable,
|
||||
let compressedPayload = CompressionUtil.compress(payload) {
|
||||
originalPayloadSize = payload.count
|
||||
payload = compressedPayload
|
||||
isCompressed = true
|
||||
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header
|
||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||
data.reserveCapacity(estimated)
|
||||
data.append(packet.version)
|
||||
|
||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||
|
||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||
|
||||
guard let headerSize = headerSize(for: version) else { return nil }
|
||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||
let estimatedPayload = payloadDataSize
|
||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||
var data = Data()
|
||||
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
||||
|
||||
data.append(version)
|
||||
data.append(packet.type)
|
||||
data.append(packet.ttl)
|
||||
|
||||
// Timestamp (8 bytes, big-endian)
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||
|
||||
for shift in stride(from: 56, through: 0, by: -8) {
|
||||
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
||||
}
|
||||
|
||||
// Flags
|
||||
|
||||
var flags: UInt8 = 0
|
||||
if packet.recipientID != nil {
|
||||
flags |= Flags.hasRecipient
|
||||
}
|
||||
if packet.signature != nil {
|
||||
flags |= Flags.hasSignature
|
||||
}
|
||||
if isCompressed {
|
||||
flags |= Flags.isCompressed
|
||||
}
|
||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
data.append(flags)
|
||||
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||
let payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
|
||||
if version == 2 {
|
||||
let length = UInt32(payloadDataSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
||||
}
|
||||
} else {
|
||||
let length = UInt16(payloadDataSize)
|
||||
data.append(UInt8((length >> 8) & 0xFF))
|
||||
data.append(UInt8(length & 0xFF))
|
||||
}
|
||||
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
data.append(senderBytes)
|
||||
if senderBytes.count < senderIDSize {
|
||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||
}
|
||||
|
||||
// RecipientID (if present)
|
||||
|
||||
if let recipientID = packet.recipientID {
|
||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||
data.append(recipientBytes)
|
||||
@@ -189,30 +211,30 @@ struct BinaryProtocol {
|
||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||
}
|
||||
}
|
||||
|
||||
// Payload (with original size prepended if compressed)
|
||||
|
||||
if isCompressed, let originalSize = originalPayloadSize {
|
||||
// Prepend original size (2 bytes, big-endian)
|
||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
||||
data.append(UInt8(originalSize & 0xFF))
|
||||
if version == 2 {
|
||||
let value = UInt32(originalSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
||||
}
|
||||
} else {
|
||||
let value = UInt16(originalSize)
|
||||
data.append(UInt8((value >> 8) & 0xFF))
|
||||
data.append(UInt8(value & 0xFF))
|
||||
}
|
||||
}
|
||||
data.append(payload)
|
||||
|
||||
// Signature (if present)
|
||||
|
||||
if let signature = packet.signature {
|
||||
data.append(signature.prefix(signatureSize))
|
||||
}
|
||||
|
||||
|
||||
// Apply padding to standard block sizes for traffic analysis resistance
|
||||
|
||||
if padding {
|
||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||
return paddedData
|
||||
} else {
|
||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||
return data
|
||||
return MessagePadding.pad(data, toSize: optimalSize)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Decode binary data to BitchatPacket
|
||||
@@ -227,87 +249,112 @@ struct BinaryProtocol {
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
||||
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
var offset = 0
|
||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||
// Read single byte
|
||||
func read8() -> UInt8? {
|
||||
guard require(1) else { return nil }
|
||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||
offset += 1
|
||||
return v
|
||||
return value
|
||||
}
|
||||
// Read big-endian 16-bit
|
||||
func read16() -> UInt16? {
|
||||
guard require(2) else { return nil }
|
||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
||||
offset += 2
|
||||
return v
|
||||
return value
|
||||
}
|
||||
func read32() -> UInt32? {
|
||||
guard require(4) else { return nil }
|
||||
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
||||
offset += 4
|
||||
return value
|
||||
}
|
||||
// Copy N bytes into Data
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
let ptr = base.advanced(by: offset)
|
||||
let d = Data(bytes: ptr, count: n)
|
||||
let data = Data(bytes: ptr, count: n)
|
||||
offset += n
|
||||
return d
|
||||
return data
|
||||
}
|
||||
|
||||
// Version
|
||||
guard let version = read8(), version == 1 else { return nil }
|
||||
guard let type = read8() else { return nil }
|
||||
guard let ttl = read8() else { return nil }
|
||||
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||
guard let headerSize = headerSize(for: version) else { return nil }
|
||||
let minimumRequired = headerSize + senderIDSize
|
||||
guard raw.count >= minimumRequired else { return nil }
|
||||
|
||||
// Timestamp 8 bytes BE
|
||||
guard require(8) else { return nil }
|
||||
var ts: UInt64 = 0
|
||||
guard let type = read8(), let ttl = read8() else { return nil }
|
||||
|
||||
var timestamp: UInt64 = 0
|
||||
for _ in 0..<8 {
|
||||
guard let b = read8() else { return nil }
|
||||
ts = (ts << 8) | UInt64(b)
|
||||
guard let byte = read8() else { return nil }
|
||||
timestamp = (timestamp << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
// Flags
|
||||
guard let flags = read8() else { return nil }
|
||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length
|
||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
guard let len = read32() else { return nil }
|
||||
payloadLength = Int(len)
|
||||
} else {
|
||||
guard let len = read16() else { return nil }
|
||||
payloadLength = Int(len)
|
||||
}
|
||||
|
||||
guard payloadLength >= 0 else { return nil }
|
||||
|
||||
// SenderID
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
// Recipient
|
||||
var recipientID: Data? = nil
|
||||
if hasRecipient {
|
||||
recipientID = readData(recipientIDSize)
|
||||
if recipientID == nil { return nil }
|
||||
}
|
||||
|
||||
// Payload
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
// Need original size (2 bytes)
|
||||
guard let origSize16 = read16() else { return nil }
|
||||
let originalSize = Int(origSize16)
|
||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||
let compSize = Int(payloadLen) - 2
|
||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||
let originalSize: Int
|
||||
if version == 2 {
|
||||
guard let rawSize = read32() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
} else {
|
||||
guard let rawSize = read16() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
}
|
||||
// Guard to keep decompression bounded to sane BLE payload limits
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||
let compressedSize = payloadLength - lengthFieldBytes
|
||||
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)
|
||||
guard compressionRatio <= 50_000.0 else {
|
||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||
decompressed.count == originalSize else { return nil }
|
||||
payload = decompressed
|
||||
} else {
|
||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
||||
payload = p
|
||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||
payload = rawPayload
|
||||
}
|
||||
|
||||
// Signature
|
||||
var signature: Data? = nil
|
||||
if hasSignature {
|
||||
signature = readData(signatureSize)
|
||||
@@ -320,10 +367,11 @@ struct BinaryProtocol {
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: ts,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl
|
||||
ttl: ttl,
|
||||
version: version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||
struct BitchatFilePacket {
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content: Data
|
||||
|
||||
/// Canonical TLV tags defined by the Android implementation.
|
||||
private enum TLVType: UInt8 {
|
||||
case fileName = 0x01
|
||||
case fileSize = 0x02
|
||||
case mimeType = 0x03
|
||||
case content = 0x04
|
||||
}
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
|
||||
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.fileName.rawValue)
|
||||
appendBE(UInt16(nameData.count), into: &encoded)
|
||||
encoded.append(nameData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.fileSize.rawValue)
|
||||
appendBE(UInt16(4), into: &encoded)
|
||||
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||
|
||||
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.mimeType.rawValue)
|
||||
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||
encoded.append(mimeData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.content.rawValue)
|
||||
appendBE(UInt32(content.count), into: &encoded)
|
||||
encoded.append(content)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content = Data()
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard cursor <= end else { return nil }
|
||||
let tlvType = TLVType(rawValue: typeRaw)
|
||||
|
||||
func readBigEndianLength(bytes: Int) -> Int? {
|
||||
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||
// Use UInt64 to prevent integer overflow during shift operations
|
||||
var result: UInt64 = 0
|
||||
for _ in 0..<bytes {
|
||||
result = (result << 8) | UInt64(data[cursor])
|
||||
cursor = data.index(after: cursor)
|
||||
}
|
||||
// Safely convert to Int with overflow check
|
||||
guard result <= Int.max else { return nil }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
let length: Int?
|
||||
if tlvType == .content {
|
||||
let snapshot = cursor
|
||||
let canonical = readBigEndianLength(bytes: 4)
|
||||
if let canonical = canonical,
|
||||
canonical <= data.distance(from: cursor, to: end) {
|
||||
length = canonical
|
||||
} else {
|
||||
cursor = snapshot
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
} else {
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
|
||||
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||
|
||||
let valueStart = cursor
|
||||
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||
let value = data[valueStart..<cursor]
|
||||
|
||||
switch tlvType {
|
||||
case .fileName:
|
||||
fileName = String(data: Data(value), encoding: .utf8)
|
||||
case .fileSize:
|
||||
if tlvLength == 4 || tlvLength == 8 {
|
||||
var size: UInt64 = 0
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
}
|
||||
case .mimeType:
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
case nil:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
mimeType: mimeType,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
@@ -78,6 +79,7 @@ enum MessageType: UInt8 {
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -88,6 +90,7 @@ enum MessageType: UInt8 {
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +134,7 @@ enum LazyHandshakeState {
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable {
|
||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
@@ -161,18 +164,21 @@ enum DeliveryStatus: Codable, Equatable {
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||
func didUpdatePeerList(_ peers: [PeerID])
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -185,11 +191,11 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
|
||||
@@ -23,15 +23,21 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .building: return "Building"
|
||||
case .block: return "Block"
|
||||
case .neighborhood: return "Neighborhood"
|
||||
case .city: return "City"
|
||||
case .province: return "Province"
|
||||
case .region: return "Region"
|
||||
case .building:
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward-compatible Codable for renamed cases
|
||||
extension GeohashChannelLevel {
|
||||
init(from decoder: Decoder) throws {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Peer ID Utilities
|
||||
|
||||
struct PeerIDUtils {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
||||
let digest = SHA256.hash(data: publicKey)
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return String(hex.prefix(16))
|
||||
}
|
||||
}
|
||||
|
||||
+2349
-1318
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
@@ -104,7 +104,7 @@ final class CommandProcessor {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
@@ -148,10 +148,10 @@ final class CommandProcessor {
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
// Also add a local system message so the sender sees a natural-language confirmation
|
||||
let pastAction: String = {
|
||||
@@ -214,7 +214,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -258,7 +258,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
private static let storageKey = "chat.bitchat.favorites"
|
||||
private static let keychainService = "chat.bitchat.favorites"
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
private init() {
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
// Update mutual favorites when favorites change
|
||||
@@ -179,125 +181,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||
guard peerID.count == 16 else { return nil }
|
||||
for (pubkey, rel) in favorites {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
guard peerID.isShort else { return nil }
|
||||
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||
return rel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Update Nostr public key for a peer
|
||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: existing.peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
/// Update nickname for an existing favorite
|
||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
// Skip if nickname hasn't changed
|
||||
if existing.peerNickname == newNickname { return }
|
||||
|
||||
// Updating nickname for favorite
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: newNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
||||
)
|
||||
}
|
||||
|
||||
/// Update noise public key when peer reconnects with new ID
|
||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
||||
guard let existing = favorites[oldKey] else {
|
||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we already have a favorite with the new key
|
||||
if favorites[newKey] != nil {
|
||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
saveFavorites()
|
||||
return
|
||||
}
|
||||
|
||||
// Updating noise public key
|
||||
|
||||
// Remove old entry
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
|
||||
// Add with new key
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: newKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[newKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers with both old and new keys
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"peerPublicKey": newKey,
|
||||
"oldPeerPublicKey": oldKey,
|
||||
"isKeyUpdate": true
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Get all favorites (including non-mutual)
|
||||
func getAllFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isFavorite }
|
||||
}
|
||||
|
||||
/// Get only mutual favorites
|
||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isMutual }
|
||||
}
|
||||
|
||||
/// Get all favorite relationships (including where they favorited us)
|
||||
func getAllRelationships() -> [FavoriteRelationship] {
|
||||
Array(favorites.values)
|
||||
}
|
||||
|
||||
/// Clear all favorites - used for panic mode
|
||||
func clearAllFavorites() {
|
||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||
@@ -306,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
saveFavorites()
|
||||
|
||||
// Delete from keychain directly
|
||||
KeychainHelper.delete(
|
||||
keychain.delete(
|
||||
key: Self.storageKey,
|
||||
service: Self.keychainService
|
||||
)
|
||||
@@ -326,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
let data = try encoder.encode(relationships)
|
||||
|
||||
// Store in keychain for security
|
||||
KeychainHelper.save(
|
||||
keychain.save(
|
||||
key: Self.storageKey,
|
||||
data: data,
|
||||
service: Self.keychainService
|
||||
service: Self.keychainService,
|
||||
accessible: nil
|
||||
)
|
||||
|
||||
// Successfully saved favorites
|
||||
@@ -341,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
private func loadFavorites() {
|
||||
// Loading favorites from keychain
|
||||
|
||||
guard let data = KeychainHelper.load(
|
||||
guard let data = keychain.load(
|
||||
key: Self.storageKey,
|
||||
service: Self.keychainService
|
||||
) else {
|
||||
|
||||
@@ -20,8 +20,11 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private let storage: UserDefaults
|
||||
|
||||
private init() {
|
||||
init(storage: UserDefaults = .standard) {
|
||||
self.storage = storage
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
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>()
|
||||
@@ -81,7 +84,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
if let namesData = storage.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
@@ -89,13 +92,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
storage.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
storage.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ final class LocationNotesCounter: ObservableObject {
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
@@ -74,7 +79,7 @@ final class LocationNotesCounter: ObservableObject {
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
|
||||
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
|
||||
var sendEvent: SendEvent
|
||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||
var now: () -> Date
|
||||
|
||||
private static let idBridge = NostrIdentityBridge()
|
||||
|
||||
static let live = LocationNotesDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
},
|
||||
deriveIdentity: { geohash in
|
||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||
},
|
||||
now: { Date() }
|
||||
)
|
||||
@@ -74,23 +76,52 @@ final class LocationNotesManager: ObservableObject {
|
||||
@Published private(set) var state: State = .loading
|
||||
@Published private(set) var errorMessage: String?
|
||||
private var subscriptionID: String?
|
||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||
private let dependencies: LocationNotesDependencies
|
||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||
|
||||
static func failedToSend(_ detail: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
self.geohash = geohash.lowercased()
|
||||
let norm = geohash.lowercased()
|
||||
self.geohash = norm
|
||||
self.dependencies = dependencies
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
if !Geohash.isValidBuildingGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
}
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -99,7 +130,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -120,7 +156,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
subscriptionID = nil
|
||||
initialLoadComplete = true
|
||||
state = .noRelays
|
||||
errorMessage = "No geo relays available near this location. Try again soon."
|
||||
errorMessage = Strings.noRelays
|
||||
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -135,12 +171,14 @@ final class LocationNotesManager: ObservableObject {
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||
if self.notes.contains(where: { $0.id == event.id }) { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||
self.notes.append(note)
|
||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||
self.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
}, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -158,7 +196,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
errorMessage = "No geo relays available near this location. Try again soon."
|
||||
errorMessage = Strings.noRelays
|
||||
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -176,15 +214,26 @@ final class LocationNotesManager: ObservableObject {
|
||||
id: event.id,
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: dependencies.now(),
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
nickname: nickname
|
||||
)
|
||||
self.noteIDs.insert(event.id)
|
||||
self.notes.insert(echo, at: 0)
|
||||
self.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
self.errorMessage = nil
|
||||
} catch {
|
||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
||||
errorMessage = Strings.failedToSend(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||
private func enforceMemoryCap() {
|
||||
if notes.count > maxNotesInMemory {
|
||||
let removed = notes.count - maxNotesInMemory
|
||||
notes = Array(notes.prefix(maxNotesInMemory))
|
||||
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Foundation
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
||||
) { [weak self] note in
|
||||
guard let self = self else { return }
|
||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
let peerID = PeerID(publicKey: data)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -37,44 +37,44 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
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.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
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 {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
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: String) {
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.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)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
// Route via mesh when connected; else use Nostr
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
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 peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
} else if peerID.count == 16 {
|
||||
} else if peerID.isShort {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
||||
return false
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: String) {
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.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)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
// Keep unsent items queued
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
|
||||
@@ -115,15 +115,30 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return "Encryption failed"
|
||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||
case .noHandshake:
|
||||
return "Not encrypted"
|
||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||
case .noiseHandshaking:
|
||||
return "Establishing encryption..."
|
||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return "Encrypted"
|
||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||
case .noiseVerified:
|
||||
return "Encrypted & Verified"
|
||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,8 +162,8 @@ final class NoiseEncryptionService {
|
||||
private let sessionManager: NoiseSessionManager
|
||||
|
||||
// Peer fingerprints (SHA256 hash of static public key)
|
||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
||||
private var peerFingerprints: [PeerID: String] = [:]
|
||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||
@@ -163,7 +178,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||
@@ -257,12 +272,11 @@ final class NoiseEncryptionService {
|
||||
|
||||
/// Get our identity fingerprint
|
||||
func getIdentityFingerprint() -> String {
|
||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||
}
|
||||
|
||||
/// Get peer's public key data
|
||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||
}
|
||||
|
||||
@@ -390,11 +404,11 @@ final class NoiseEncryptionService {
|
||||
// MARK: - Handshake Management
|
||||
|
||||
/// Initiate a Noise handshake with a peer
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
|
||||
// Validate peer ID
|
||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
@@ -404,7 +418,7 @@ final class NoiseEncryptionService {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
|
||||
// Return raw handshake data without wrapper
|
||||
// The Noise protocol handles its own message format
|
||||
@@ -413,17 +427,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
|
||||
// Validate peer ID
|
||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
||||
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -443,19 +457,19 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Check if we have an established session with a peer
|
||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
||||
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||
}
|
||||
|
||||
/// Check if we have a session (established or handshaking) with a peer
|
||||
func hasSession(with peerID: String) -> Bool {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
/// Encrypt data for a specific peer
|
||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
||||
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -477,7 +491,7 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -499,32 +513,11 @@ final class NoiseEncryptionService {
|
||||
// MARK: - Peer Management
|
||||
|
||||
/// Get fingerprint for a peer
|
||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||
return serviceQueue.sync {
|
||||
return peerFingerprints[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
/// Get peer ID for a fingerprint
|
||||
func getPeerID(for fingerprint: String) -> String? {
|
||||
return serviceQueue.sync {
|
||||
return fingerprintToPeerID[fingerprint]
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a peer session
|
||||
func removePeer(_ peerID: String) {
|
||||
sessionManager.removeSession(for: peerID)
|
||||
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
if let fingerprint = peerFingerprints[peerID] {
|
||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||
}
|
||||
peerFingerprints.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
@@ -537,9 +530,9 @@ final class NoiseEncryptionService {
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Calculate fingerprint
|
||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||
|
||||
// Store fingerprint mapping
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
@@ -548,20 +541,15 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
// Log security event
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
handler(peerID.id, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Session Maintenance
|
||||
|
||||
|
||||
@@ -4,46 +4,51 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID: String = ""
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits
|
||||
private struct QueuedRead {
|
||||
let receipt: ReadReceipt
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
}
|
||||
private var readQueue: [QueuedRead] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
}
|
||||
|
||||
// MARK: - Transport Protocol Conformance
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
var myPeerID: PeerID { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
func startServices() { /* no-op */ }
|
||||
func stopServices() { /* no-op */ }
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||
func peerNickname(peerID: String) -> String? { nil }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
@@ -59,11 +64,11 @@ final class NostrTransport: Transport {
|
||||
// Public broadcast not supported over Nostr here
|
||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard 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)
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
let recipientHex: String
|
||||
do {
|
||||
@@ -77,7 +82,7 @@ final class NostrTransport: Transport {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -90,12 +95,113 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
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 sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
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)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
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.id) else { return }
|
||||
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) {
|
||||
Task { @MainActor in
|
||||
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.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
guard !readQueue.isEmpty else { return }
|
||||
@@ -108,7 +214,7 @@ final class NostrTransport: Transport {
|
||||
let item = readQueue.removeFirst()
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
@@ -117,7 +223,7 @@ final class NostrTransport: Transport {
|
||||
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, senderPeerID: senderPeerID) else {
|
||||
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)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
@@ -139,111 +245,18 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// 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, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.count == 16,
|
||||
if peerID.id.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
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, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
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 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) {
|
||||
Task { @MainActor in
|
||||
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 event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,26 +70,6 @@ final class NotificationService {
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendFavoriteOnlineNotification(nickname: String) {
|
||||
// Send directly without checking app state for favorites
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "⭐ \(nickname) is online!"
|
||||
content.body = "wanna get in there?"
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "favorite-online-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// NotificationStreamAssembler.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
private var buffer = Data()
|
||||
|
||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||
guard !chunk.isEmpty else { return ([], [], false) }
|
||||
|
||||
buffer.append(chunk)
|
||||
|
||||
var frames: [Data] = []
|
||||
var dropped: [UInt8] = []
|
||||
var reset = false
|
||||
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||
|
||||
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||
|
||||
while buffer.count >= minFramePrefix {
|
||||
guard let first = buffer.first else { break }
|
||||
if first != 1 {
|
||||
dropped.append(buffer.removeFirst())
|
||||
continue
|
||||
}
|
||||
|
||||
guard buffer.count >= minHeaderBytes else { break }
|
||||
|
||||
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||
guard headerBytes.count == minFramePrefix else { break }
|
||||
|
||||
let flags = headerBytes[11]
|
||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||
|
||||
var frameLength = minFramePrefix + payloadLen
|
||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||
|
||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||
buffer.removeAll()
|
||||
reset = true
|
||||
break
|
||||
}
|
||||
|
||||
if buffer.count < frameLength {
|
||||
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||
if dropCount > 0 {
|
||||
buffer.removeFirst(dropCount)
|
||||
dropped.append(1) // treat as dropped partial start
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
let frame = Data(buffer.prefix(frameLength))
|
||||
frames.append(frame)
|
||||
buffer.removeFirst(frameLength)
|
||||
}
|
||||
|
||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
return (frames, dropped, reset)
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import SwiftUI
|
||||
|
||||
/// Manages all private chat functionality
|
||||
final class PrivateChatManager: ObservableObject {
|
||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: String? = nil
|
||||
@Published var unreadMessages: Set<String> = []
|
||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: PeerID? = nil
|
||||
@Published var unreadMessages: Set<PeerID> = []
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
@@ -31,7 +31,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
|
||||
/// Start a private chat with a peer
|
||||
func startChat(with peerID: String) {
|
||||
func startChat(with peerID: PeerID) {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
@@ -53,109 +53,33 @@ final class PrivateChatManager: ObservableObject {
|
||||
selectedPeer = nil
|
||||
selectedPeerFingerprint = nil
|
||||
}
|
||||
|
||||
/// Send a private message
|
||||
func sendMessage(_ content: String, to peerID: String) {
|
||||
guard let meshService = meshService,
|
||||
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
// Create local message
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: meshService.myNickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: peerNickname,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
// Add to chat
|
||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||||
privateChats[peerID]?.append(message)
|
||||
// Enforce per-chat cap on local append
|
||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[peerID] = arr
|
||||
}
|
||||
|
||||
// Send via mesh service
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
||||
}
|
||||
|
||||
/// Handle incoming private message
|
||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||
guard let senderPeerID = message.senderPeerID else { return }
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[senderPeerID] == nil {
|
||||
privateChats[senderPeerID] = []
|
||||
}
|
||||
|
||||
// Deduplicate by ID: replace existing message if present, else append
|
||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
privateChats[senderPeerID]?[idx] = message
|
||||
} else {
|
||||
privateChats[senderPeerID]?.append(message)
|
||||
}
|
||||
|
||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
||||
sanitizeChat(for: senderPeerID)
|
||||
// Enforce cap after sanitize
|
||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[senderPeerID] = arr
|
||||
}
|
||||
|
||||
// Mark as unread if not in this chat
|
||||
if selectedPeer != senderPeerID {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: senderPeerID
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send read receipt if viewing this chat
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove duplicate messages by ID and keep chronological order
|
||||
func sanitizeChat(for peerID: String) {
|
||||
func sanitizeChat(for peerID: PeerID) {
|
||||
guard let arr = privateChats[peerID] else { return }
|
||||
var seen = Set<String>()
|
||||
if arr.count <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
var indexByID: [String: Int] = [:]
|
||||
indexByID.reserveCapacity(arr.count)
|
||||
var deduped: [BitchatMessage] = []
|
||||
deduped.reserveCapacity(arr.count)
|
||||
|
||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||
if !seen.contains(msg.id) {
|
||||
seen.insert(msg.id)
|
||||
deduped.append(msg)
|
||||
if let existing = indexByID[msg.id] {
|
||||
deduped[existing] = msg
|
||||
} else {
|
||||
// Replace previous with the latest occurrence (which is later in sort)
|
||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
||||
deduped[index] = msg
|
||||
}
|
||||
indexByID[msg.id] = deduped.count
|
||||
deduped.append(msg)
|
||||
}
|
||||
}
|
||||
|
||||
privateChats[peerID] = deduped
|
||||
}
|
||||
|
||||
/// Mark messages from a peer as read
|
||||
func markAsRead(from peerID: String) {
|
||||
func markAsRead(from peerID: PeerID) {
|
||||
unreadMessages.remove(peerID)
|
||||
|
||||
// Send read receipts for unread messages that haven't been sent yet
|
||||
@@ -168,48 +92,6 @@ final class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
||||
func updateSelectedPeer(peers: [String: String]) {
|
||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
||||
|
||||
// Find peer with matching fingerprint
|
||||
for (peerID, _) in peers {
|
||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
||||
selectedPeer = peerID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get chat messages for current context
|
||||
func getCurrentMessages() -> [BitchatMessage] {
|
||||
guard let peer = selectedPeer else { return [] }
|
||||
return privateChats[peer] ?? []
|
||||
}
|
||||
|
||||
/// Clear a private chat
|
||||
func clearChat(with peerID: String) {
|
||||
privateChats[peerID]?.removeAll()
|
||||
}
|
||||
|
||||
/// Handle delivery acknowledgment
|
||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle read receipt
|
||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
@@ -223,13 +105,13 @@ final class PrivateChatManager: ObservableObject {
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? "",
|
||||
readerID: meshService?.myPeerID.id ?? "",
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||
if let router = messageRouter {
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||
Task { @MainActor in
|
||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Centralized progress bus for Bluetooth file transfers.
|
||||
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
|
||||
final class TransferProgressManager {
|
||||
static let shared = TransferProgressManager()
|
||||
|
||||
enum Event {
|
||||
case started(id: String, totalFragments: Int)
|
||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||
case completed(id: String, totalFragments: Int)
|
||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||
}
|
||||
|
||||
private let subject = PassthroughSubject<Event, Never>()
|
||||
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
|
||||
private var states: [String: (sent: Int, total: Int)] = [:]
|
||||
|
||||
var publisher: AnyPublisher<Event, Never> {
|
||||
subject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func start(id: String, totalFragments: Int) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.states[id] = (sent: 0, total: totalFragments)
|
||||
self.subject.send(.started(id: id, totalFragments: totalFragments))
|
||||
}
|
||||
}
|
||||
|
||||
func recordFragmentSent(id: String) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self, var state = self.states[id] else { return }
|
||||
state.sent = min(state.sent + 1, state.total)
|
||||
self.states[id] = state
|
||||
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||
if state.sent >= state.total {
|
||||
self.states.removeValue(forKey: id)
|
||||
self.subject.send(.completed(id: id, totalFragments: state.total))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel(id: String) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
|
||||
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||
}
|
||||
}
|
||||
|
||||
func reset(id: String) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.states.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||
var result: (sent: Int, total: Int)?
|
||||
queue.sync {
|
||||
result = states[id]
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Combine
|
||||
/// Abstract transport interface used by ChatViewModel and services.
|
||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let id: String
|
||||
let peerID: PeerID
|
||||
let nickname: String
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
|
||||
// Identity
|
||||
var myPeerID: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var myNickname: String { get }
|
||||
func setNickname(_ nickname: String)
|
||||
|
||||
@@ -28,37 +32,39 @@ protocol Transport: AnyObject {
|
||||
func emergencyDisconnectAll()
|
||||
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: String) -> Bool
|
||||
func isPeerReachable(_ peerID: String) -> Bool
|
||||
func peerNickname(peerID: String) -> String?
|
||||
func getPeerNicknames() -> [String: String]
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
// Protocol utilities
|
||||
func getFingerprint(for peerID: String) -> String?
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: String)
|
||||
func getFingerprint(for peerID: PeerID) -> String?
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func getNoiseService() -> NoiseEncryptionService
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -30,7 +30,11 @@ enum TransportConfig {
|
||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||
static let bleConnectionCandidatesMax: Int = 100
|
||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||
static let blePendingNotificationsCapCount: Int = 20
|
||||
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
||||
static let bleAssemblerStallResetMs: Int = 250
|
||||
static let blePendingNotificationsCapCount: Int = 128
|
||||
static let bleNotificationRetryDelayMs: Int = 25
|
||||
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||
|
||||
// Nostr
|
||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
|
||||
@@ -10,7 +10,6 @@ import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||
@MainActor
|
||||
@@ -19,15 +18,16 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var peers: [BitchatPeer] = []
|
||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
||||
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
||||
@Published private(set) var favorites: [BitchatPeer] = []
|
||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [PeerID: String] = [:]
|
||||
private let meshService: Transport
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
weak var messageRouter: MessageRouter?
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
@@ -35,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
init(
|
||||
meshService: Transport,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol
|
||||
) {
|
||||
self.meshService = meshService
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
|
||||
// Subscribe to changes from both services
|
||||
@@ -78,12 +83,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.id
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
@@ -104,7 +109,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
let peerID = favoriteKey.hexEncodedString()
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
@@ -138,10 +143,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// Phase 4: Build subsets and indices
|
||||
var favoritesList: [BitchatPeer] = []
|
||||
var mutualsList: [BitchatPeer] = []
|
||||
var newIndex: [String: BitchatPeer] = [:]
|
||||
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||
|
||||
for peer in enrichedPeers {
|
||||
newIndex[peer.id] = peer
|
||||
newIndex[peer.peerID] = peer
|
||||
|
||||
if peer.isFavorite {
|
||||
favoritesList.append(peer)
|
||||
@@ -185,7 +190,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerInfo.id,
|
||||
peerID: peerInfo.peerID,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
@@ -205,10 +210,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func buildPeerFromFavorite(
|
||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||
peerID: String
|
||||
peerID: PeerID
|
||||
) -> BitchatPeer {
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
peerID: peerID,
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
@@ -225,27 +230,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Get peer by ID
|
||||
func getPeer(by id: String) -> BitchatPeer? {
|
||||
return peerIndex[id]
|
||||
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||
return peerIndex[peerID]
|
||||
}
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> String? {
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
return peer.id
|
||||
return peer.peerID.id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if peer is online
|
||||
func isOnline(_ peerID: String) -> Bool {
|
||||
return connectedPeerIDs.contains(peerID)
|
||||
}
|
||||
|
||||
/// Check if peer is blocked
|
||||
func isBlocked(_ peerID: String) -> Bool {
|
||||
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||
// Get fingerprint
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||
|
||||
@@ -258,8 +258,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: String) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -291,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var peerNostrKey = peer.nostrPublicKey
|
||||
if peerNostrKey == nil {
|
||||
// Try to get from NostrIdentityBridge association
|
||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||
}
|
||||
|
||||
// Add favorite
|
||||
@@ -322,39 +322,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle blocked status
|
||||
func toggleBlocked(_ peerID: String) {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||
|
||||
// Get or create social identity
|
||||
var identity = identityManager.getSocialIdentity(for: fingerprint)
|
||||
?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
|
||||
// Toggle blocked status
|
||||
identity.isBlocked = !identity.isBlocked
|
||||
|
||||
// Can't be both favorite and blocked
|
||||
if identity.isBlocked {
|
||||
identity.isFavorite = false
|
||||
// Also remove from favorites service
|
||||
if let peer = getPeer(by: peerID) {
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
identityManager.updateSocialIdentity(identity)
|
||||
}
|
||||
|
||||
/// Get fingerprint for peer ID
|
||||
func getFingerprint(for peerID: String) -> String? {
|
||||
func getFingerprint(for peerID: PeerID) -> String? {
|
||||
// Check cache first
|
||||
if let cached = fingerprintCache[peerID] {
|
||||
return cached
|
||||
@@ -379,23 +347,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Compatibility Methods (for easy migration)
|
||||
|
||||
var allPeers: [BitchatPeer] { peers }
|
||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
||||
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||
}
|
||||
var blockedUsers: Set<String> {
|
||||
Set(peers.compactMap { peer in
|
||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
||||
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
extension Data {
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
let hash = SHA256.hash(data: self)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||
final class VerificationService {
|
||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
||||
nickname: payload.nickname,
|
||||
ts: payload.ts,
|
||||
nonceB64: payload.nonceB64,
|
||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||
sigHex: sig.hexEncodedString())
|
||||
let out = signed.toURLString()
|
||||
Cache.last = (nickname, npub, Date(), out)
|
||||
return out
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
final class GossipSyncManager {
|
||||
protocol Delegate: AnyObject {
|
||||
func sendPacket(_ packet: BitchatPacket)
|
||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ final class GossipSyncManager {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
var gcsTargetFpr: Double = 0.01 // 1%
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
}
|
||||
|
||||
private let myPeerID: String
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
weak var delegate: Delegate?
|
||||
|
||||
@@ -26,8 +30,9 @@ final class GossipSyncManager {
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
|
||||
init(myPeerID: String, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
}
|
||||
@@ -35,8 +40,11 @@ final class GossipSyncManager {
|
||||
func start() {
|
||||
stop()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
||||
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
||||
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.performPeriodicMaintenance()
|
||||
}
|
||||
timer.resume()
|
||||
periodicTimer = timer
|
||||
}
|
||||
@@ -45,7 +53,7 @@ final class GossipSyncManager {
|
||||
periodicTimer?.cancel(); periodicTimer = nil
|
||||
}
|
||||
|
||||
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID)
|
||||
}
|
||||
@@ -57,6 +65,27 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a packet is within the age threshold
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||
|
||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||
guard nowMs >= ageThresholdMs else { return true }
|
||||
|
||||
let cutoffMs = nowMs - ageThresholdMs
|
||||
return packet.timestamp >= cutoffMs
|
||||
}
|
||||
|
||||
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
||||
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||
guard nowMs >= timeoutMs else { return true }
|
||||
let cutoffMs = nowMs - timeoutMs
|
||||
return packet.timestamp >= cutoffMs
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
@@ -67,6 +96,17 @@ final class GossipSyncManager {
|
||||
let isAnnounce = (mt == .announce)
|
||||
guard isBroadcastMessage || isAnnounce else { return }
|
||||
|
||||
// Reject expired packets to prevent ghost peers and old messages
|
||||
guard isPacketFresh(packet) else { return }
|
||||
|
||||
if isAnnounce {
|
||||
guard isAnnouncementFresh(packet) else {
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
removeState(forNormalizedPeerID: sender)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
|
||||
if isBroadcastMessage {
|
||||
@@ -81,7 +121,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
} else if isAnnounce {
|
||||
let sender = packet.senderID.hexEncodedString()
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +130,7 @@ final class GossipSyncManager {
|
||||
let payload = buildGcsPayload()
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
recipientID: nil, // broadcast
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -101,10 +141,10 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: String) {
|
||||
private func sendRequestSync(to peerID: PeerID) {
|
||||
let payload = buildGcsPayload()
|
||||
var recipient = Data()
|
||||
var temp = peerID
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
let hexByte = String(temp.prefix(2))
|
||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||
@@ -112,7 +152,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
recipientID: recipient,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -123,13 +163,13 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(to: peerID, packet: signed)
|
||||
}
|
||||
|
||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
queue.async { [weak self] in
|
||||
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
||||
self?._handleRequestSync(from: peerID, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
// Decode GCS into sorted set and prepare membership checker
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -137,36 +177,46 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peer if requester lacks them
|
||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all missing
|
||||
// 2) Broadcast messages: send all missing (and not expired)
|
||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||
for pkt in toSendMsgs {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload() -> Data {
|
||||
// Collect candidates: latest announce per peer + broadcast messages
|
||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||
var candidates: [BitchatPacket] = []
|
||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
if isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
for id in messageOrder {
|
||||
if let p = messages[id], isPacketFresh(p) {
|
||||
candidates.append(p)
|
||||
}
|
||||
}
|
||||
// Sort by timestamp desc
|
||||
candidates.sort { $0.timestamp > $1.timestamp }
|
||||
|
||||
@@ -184,17 +234,65 @@ final class GossipSyncManager {
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired messages and announcements
|
||||
private func cleanupExpiredMessages() {
|
||||
// Remove expired announcements
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
// Remove expired messages
|
||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||
isPacketFresh(pkt) ? nil : id
|
||||
}
|
||||
for id in expiredMessageIds {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
sendRequestSync()
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
||||
return
|
||||
}
|
||||
lastStalePeerCleanup = now
|
||||
cleanupStaleAnnouncements(now: now)
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncements(now: Date) {
|
||||
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMs >= timeoutMs else { return }
|
||||
let cutoff = nowMs - timeoutMs
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||
}
|
||||
guard !stalePeerIDs.isEmpty else { return }
|
||||
for peerKey in stalePeerIDs {
|
||||
removeState(forNormalizedPeerID: peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: String) {
|
||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||
let normalizedPeerID = peerID.lowercased()
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.id.lowercased()
|
||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -208,3 +306,25 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GossipSyncManager {
|
||||
func _performMaintenanceSynchronously(now: Date = Date()) {
|
||||
queue.sync {
|
||||
performPeriodicMaintenance(now: now)
|
||||
}
|
||||
}
|
||||
|
||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||
}
|
||||
}
|
||||
|
||||
func _messageCount(for peerID: PeerID) -> Int {
|
||||
queue.sync {
|
||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
||||
let digest = hasher.finalize()
|
||||
return Data(digest.prefix(16))
|
||||
}
|
||||
|
||||
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
||||
return computeId(packet).hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Data+SHA256.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 26/09/2025.
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct CryptoKit.SHA256
|
||||
|
||||
extension Data {
|
||||
/// Returns the hex representation of SHA256 hash
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
sha256Hash().hexEncodedString()
|
||||
}
|
||||
|
||||
/// Returns the SHA256 hash wrapped in Data
|
||||
func sha256Hash() -> Data {
|
||||
Data(SHA256.hash(data: self))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||
enum FileTransferLimits {
|
||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Voice notes stay small for low-latency relays.
|
||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
|
||||
static func isValidPayload(_ size: Int) -> Bool {
|
||||
size <= maxPayloadBytes
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,19 @@ private extension Font.TextStyle {
|
||||
return .caption2
|
||||
case ..<13.0:
|
||||
return .caption
|
||||
case ..<14.0:
|
||||
case ..<13.75:
|
||||
return .footnote
|
||||
case ..<16.5:
|
||||
case ..<15.5:
|
||||
return .subheadline
|
||||
case ..<17.5:
|
||||
return .callout
|
||||
case ..<19.5:
|
||||
return .body
|
||||
case ..<19.0:
|
||||
case ..<22.5:
|
||||
return .title3
|
||||
case ..<23.0:
|
||||
case ..<27.5:
|
||||
return .title2
|
||||
case ..<30.0:
|
||||
case ..<34.0:
|
||||
return .title
|
||||
default:
|
||||
return .largeTitle
|
||||
|
||||
@@ -11,51 +11,24 @@ struct InputValidator {
|
||||
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||
// for headers/padding by limiting user content to 60_000 bytes.
|
||||
static let maxMessageLength = 60_000
|
||||
static let maxReasonLength = 200
|
||||
static let maxPeerIDLength = 64
|
||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
// MARK: - Peer ID Validation
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
// Accept short routing IDs (exact 16-hex)
|
||||
if PeerIDResolver.isShortID(peerID) { return true }
|
||||
// If length equals short-hex length but isn't valid hex, reject
|
||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
||||
// Accept full Noise key hex (exact 64-hex)
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
||||
// If length equals full key length but isn't valid hex, reject
|
||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !peerID.isEmpty &&
|
||||
peerID.count < Limits.maxPeerIDLength &&
|
||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
// MARK: - String Content Validation
|
||||
|
||||
/// Validates and sanitizes user-provided strings (nicknames, messages)
|
||||
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
|
||||
/// Validates and sanitizes user-provided strings used in UI
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
// Check empty
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Trim whitespace
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Check length
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Remove control characters except allowed ones
|
||||
var allowedControlChars = CharacterSet()
|
||||
if allowNewlines {
|
||||
allowedControlChars.insert(charactersIn: "\n\r")
|
||||
}
|
||||
|
||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
||||
|
||||
// Remove control characters
|
||||
let controlChars = CharacterSet.controlCharacters
|
||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||
|
||||
// Ensure valid UTF-8 (should already be, but double-check)
|
||||
@@ -70,29 +43,14 @@ struct InputValidator {
|
||||
|
||||
/// Validates nickname
|
||||
static func validateNickname(_ nickname: String) -> String? {
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
|
||||
}
|
||||
|
||||
/// Validates message content
|
||||
static func validateMessageContent(_ content: String) -> String? {
|
||||
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
|
||||
}
|
||||
|
||||
/// Validates error/reason strings
|
||||
static func validateReasonString(_ reason: String) -> String? {
|
||||
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Field Validation
|
||||
|
||||
|
||||
// Note: Message type validation is performed closer to decoding using
|
||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||
|
||||
/// Validates hop count is reasonable
|
||||
static func validateHopCount(_ hopCount: UInt8) -> Bool {
|
||||
return hopCount <= 10 // Prevent excessive forwarding
|
||||
}
|
||||
|
||||
|
||||
/// Validates timestamp is reasonable (not too far in past or future)
|
||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||
let now = Date()
|
||||
@@ -100,38 +58,5 @@ struct InputValidator {
|
||||
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||
}
|
||||
|
||||
/// Validates data size for different contexts
|
||||
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
|
||||
return data.count > 0 && data.count <= maxSize
|
||||
}
|
||||
|
||||
// MARK: - Binary Data Validation
|
||||
|
||||
/// Validates UUID format
|
||||
static func validateUUID(_ uuid: String) -> Bool {
|
||||
// Remove dashes and validate hex
|
||||
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
|
||||
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
/// Validates public key data
|
||||
static func validatePublicKey(_ keyData: Data) -> Bool {
|
||||
// Curve25519 public keys are 32 bytes
|
||||
return keyData.count == 32
|
||||
}
|
||||
|
||||
/// Validates signature data
|
||||
static func validateSignature(_ signature: Data) -> Bool {
|
||||
// Ed25519 signatures are 64 bytes
|
||||
return signature.count == 64
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Character Extensions
|
||||
|
||||
private extension Character {
|
||||
var isHexDigit: Bool {
|
||||
return "0123456789abcdefABCDEF".contains(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import Foundation
|
||||
struct PeerDisplayNameResolver {
|
||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||
/// - Parameters:
|
||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
||||
/// - peers: Array of tuples (peerID, nickname, isConnected).
|
||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||
/// - Returns: Map of peerID -> displayName.
|
||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
||||
static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
|
||||
// Count collisions among connected peers and include our own nickname
|
||||
var counts: [String: Int] = [:]
|
||||
for p in peers where p.isConnected {
|
||||
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
|
||||
}
|
||||
counts[selfNickname, default: 0] += 1
|
||||
|
||||
var result: [String: String] = [:]
|
||||
var result: [PeerID: String] = [:]
|
||||
for p in peers {
|
||||
var name = p.nickname
|
||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||
name += "#" + String(p.id.prefix(4))
|
||||
name += "#" + String(p.peerID.id.prefix(4))
|
||||
}
|
||||
result[p.id] = name
|
||||
result[p.peerID] = name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct PeerIDResolver {
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
static func toShortID(_ id: String) -> String {
|
||||
if id.count == 64, let data = Data(hexString: id) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
static func isShortID(_ id: String) -> Bool {
|
||||
return id.count == 16 && Data(hexString: id) != nil
|
||||
}
|
||||
|
||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
||||
return id.count == 64 && Data(hexString: id) != nil
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,41 +18,77 @@ struct AppInfoView: View {
|
||||
|
||||
// MARK: - Constants
|
||||
private enum Strings {
|
||||
static let appName = "bitchat"
|
||||
static let tagline = "sidegroupchat"
|
||||
|
||||
static let appName: LocalizedStringKey = "app_info.app_name"
|
||||
static let tagline: LocalizedStringKey = "app_info.tagline"
|
||||
|
||||
enum Features {
|
||||
static let title = "FEATURES"
|
||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
||||
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays")
|
||||
static let title: LocalizedStringKey = "app_info.features.title"
|
||||
static let offlineComm = AppInfoFeatureInfo(
|
||||
icon: "wifi.slash",
|
||||
title: "app_info.features.offline.title",
|
||||
description: "app_info.features.offline.description"
|
||||
)
|
||||
static let encryption = AppInfoFeatureInfo(
|
||||
icon: "lock.shield",
|
||||
title: "app_info.features.encryption.title",
|
||||
description: "app_info.features.encryption.description"
|
||||
)
|
||||
static let extendedRange = AppInfoFeatureInfo(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "app_info.features.extended_range.title",
|
||||
description: "app_info.features.extended_range.description"
|
||||
)
|
||||
static let mentions = AppInfoFeatureInfo(
|
||||
icon: "at",
|
||||
title: "app_info.features.mentions.title",
|
||||
description: "app_info.features.mentions.description"
|
||||
)
|
||||
static let favorites = AppInfoFeatureInfo(
|
||||
icon: "star.fill",
|
||||
title: "app_info.features.favorites.title",
|
||||
description: "app_info.features.favorites.description"
|
||||
)
|
||||
static let geohash = AppInfoFeatureInfo(
|
||||
icon: "number",
|
||||
title: "app_info.features.geohash.title",
|
||||
description: "app_info.features.geohash.description"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
enum Privacy {
|
||||
static let title = "PRIVACY"
|
||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
icon: "eye.slash",
|
||||
title: "app_info.privacy.no_tracking.title",
|
||||
description: "app_info.privacy.no_tracking.description"
|
||||
)
|
||||
static let ephemeral = AppInfoFeatureInfo(
|
||||
icon: "shuffle",
|
||||
title: "app_info.privacy.ephemeral.title",
|
||||
description: "app_info.privacy.ephemeral.description"
|
||||
)
|
||||
static let panic = AppInfoFeatureInfo(
|
||||
icon: "hand.raised.fill",
|
||||
title: "app_info.privacy.panic.title",
|
||||
description: "app_info.privacy.panic.description"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
enum HowToUse {
|
||||
static let title = "HOW TO USE"
|
||||
static let instructions = [
|
||||
"• set your nickname by tapping it",
|
||||
"• tap #mesh to change channels",
|
||||
"• tap people icon for sidebar",
|
||||
"• tap a peer's name to start a DM",
|
||||
"• triple-tap chat to clear",
|
||||
"• type / for commands"
|
||||
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||
static let instructions: [LocalizedStringKey] = [
|
||||
"app_info.how_to_use.set_nickname",
|
||||
"app_info.how_to_use.change_channels",
|
||||
"app_info.how_to_use.open_sidebar",
|
||||
"app_info.how_to_use.start_dm",
|
||||
"app_info.how_to_use.clear_chat",
|
||||
"app_info.how_to_use.commands"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
enum Warning {
|
||||
static let title = "WARNING"
|
||||
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears."
|
||||
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +98,7 @@ struct AppInfoView: View {
|
||||
// Custom header for macOS
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("DONE") {
|
||||
Button("app_info.done") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -93,7 +129,7 @@ struct AppInfoView: View {
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
.accessibilityLabel("app_info.close")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +157,7 @@ struct AppInfoView: View {
|
||||
SectionHeader(Strings.HowToUse.title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
||||
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
|
||||
Text(instruction)
|
||||
}
|
||||
}
|
||||
@@ -132,49 +168,31 @@ struct AppInfoView: View {
|
||||
// Features
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
|
||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
||||
title: Strings.Features.offlineComm.1,
|
||||
description: Strings.Features.offlineComm.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.encryption.0,
|
||||
title: Strings.Features.encryption.1,
|
||||
description: Strings.Features.encryption.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
||||
title: Strings.Features.extendedRange.1,
|
||||
description: Strings.Features.extendedRange.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.favorites.0,
|
||||
title: Strings.Features.favorites.1,
|
||||
description: Strings.Features.favorites.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.geohash.0,
|
||||
title: Strings.Features.geohash.1,
|
||||
description: Strings.Features.geohash.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.mentions.0,
|
||||
title: Strings.Features.mentions.1,
|
||||
description: Strings.Features.mentions.2)
|
||||
|
||||
FeatureRow(info: Strings.Features.offlineComm)
|
||||
|
||||
FeatureRow(info: Strings.Features.encryption)
|
||||
|
||||
FeatureRow(info: Strings.Features.extendedRange)
|
||||
|
||||
FeatureRow(info: Strings.Features.favorites)
|
||||
|
||||
FeatureRow(info: Strings.Features.geohash)
|
||||
|
||||
FeatureRow(info: Strings.Features.mentions)
|
||||
}
|
||||
|
||||
|
||||
// Privacy
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
||||
title: Strings.Privacy.noTracking.1,
|
||||
description: Strings.Privacy.noTracking.2)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
||||
title: Strings.Privacy.ephemeral.1,
|
||||
description: Strings.Privacy.ephemeral.2)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.panic.0,
|
||||
title: Strings.Privacy.panic.1,
|
||||
description: Strings.Privacy.panic.2)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.noTracking)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.panic)
|
||||
}
|
||||
|
||||
|
||||
// Warning
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionHeader(Strings.Warning.title)
|
||||
@@ -197,15 +215,21 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppInfoFeatureInfo {
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
}
|
||||
|
||||
struct SectionHeader: View {
|
||||
let title: String
|
||||
let title: LocalizedStringKey
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
init(_ title: String) {
|
||||
init(_ title: LocalizedStringKey) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
@@ -218,9 +242,7 @@ struct SectionHeader: View {
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let info: AppInfoFeatureInfo
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
@@ -233,17 +255,17 @@ struct FeatureRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
Image(systemName: info.icon)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
Text(info.title)
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(description)
|
||||
Text(info.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// DeliveryStatusView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let status: DeliveryStatus
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
private var secondaryTextColor: Color {
|
||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||
}
|
||||
|
||||
private enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Text(verbatim: "\(reached)/\(total)")
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help(Strings.deliveredToMembers(reached, total))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.sending,
|
||||
.sent,
|
||||
.delivered(to: "John Doe", at: Date()),
|
||||
.read(by: "Jane Doe", at: Date()),
|
||||
.failed(reason: "Offline"),
|
||||
.partiallyDelivered(reached: 2, total: 5)
|
||||
]
|
||||
|
||||
List {
|
||||
ForEach(statuses, id: \.self) { status in
|
||||
HStack {
|
||||
Text(status.displayText)
|
||||
Spacer()
|
||||
DeliveryStatusView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
ForEach(statuses, id: \.self) { status in
|
||||
HStack {
|
||||
Text(status.displayText)
|
||||
Spacer()
|
||||
DeliveryStatusView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// PaymentChipView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PaymentChipView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
enum PaymentType {
|
||||
case cashu(String)
|
||||
case lightning(String)
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .cashu(let link), .lightning(let link):
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .cashu: "🥜"
|
||||
case .lightning: "⚡"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .cashu:
|
||||
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||
case .lightning:
|
||||
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let paymentType: PaymentType
|
||||
|
||||
private var fgColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
if let url = paymentType.url { openURL(url) }
|
||||
#else
|
||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(paymentType.emoji)
|
||||
Text(paymentType.label)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(bgColor)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(border, lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(fgColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let cashuLink = "https://example.com/cashu"
|
||||
let lightningLink = "https://example.com/lightning"
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// TextMessageView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@Binding var expandedMessageIDs: Set<String>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuLinks = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Expand/Collapse for very long messages
|
||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
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)
|
||||
}
|
||||
|
||||
// Render payment chips (Lightning / Cashu) with rounded background
|
||||
if !lightningLinks.isEmpty || !cashuLinks.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(lightningLinks, id: \.self) { link in
|
||||
PaymentChipView(paymentType: .lightning(link))
|
||||
}
|
||||
ForEach(cashuLinks, id: \.self) { link in
|
||||
PaymentChipView(paymentType: .cashu(link))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14, iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var ids: Set<String> = []
|
||||
let keychain = PreviewKeychainManager()
|
||||
|
||||
Group {
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.environmentObject(
|
||||
ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
}
|
||||
+1340
-844
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,35 @@ struct FingerprintView: View {
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "fingerprint.title"
|
||||
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
|
||||
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
|
||||
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
|
||||
static let copy: LocalizedStringKey = "common.copy"
|
||||
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
|
||||
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||
static func unknownPeer() -> String {
|
||||
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("SECURITY VERIFICATION")
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
@@ -49,7 +72,7 @@ struct FingerprintView: View {
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: 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 peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
@@ -58,7 +81,7 @@ struct FingerprintView: View {
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
return Strings.unknownPeer()
|
||||
}()
|
||||
// Accurate encryption state based on short ID session
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
@@ -88,7 +111,7 @@ struct FingerprintView: View {
|
||||
|
||||
// Their fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("THEIR FINGERPRINT:")
|
||||
Text(Strings.theirFingerprint)
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
@@ -104,7 +127,7 @@ struct FingerprintView: View {
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button("Copy") {
|
||||
Button(Strings.copy) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = fingerprint
|
||||
#else
|
||||
@@ -114,16 +137,16 @@ struct FingerprintView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("not available - handshake in progress")
|
||||
Text(Strings.handshakePending)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// My fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("YOUR FINGERPRINT:")
|
||||
Text(Strings.yourFingerprint)
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
@@ -139,7 +162,7 @@ struct FingerprintView: View {
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button("Copy") {
|
||||
Button(Strings.copy) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = myFingerprint
|
||||
#else
|
||||
@@ -155,14 +178,18 @@ struct FingerprintView: View {
|
||||
let isVerified = encryptionStatus == .noiseVerified
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text(isVerified ?
|
||||
"you have verified this person's identity." :
|
||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
||||
Group {
|
||||
if isVerified {
|
||||
Text(Strings.verifiedMessage)
|
||||
} else {
|
||||
Text(Strings.verifyHint(peerNickname))
|
||||
}
|
||||
}
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -175,7 +202,7 @@ struct FingerprintView: View {
|
||||
viewModel.verifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text("MARK AS VERIFIED")
|
||||
Text(Strings.markVerified)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
@@ -189,7 +216,7 @@ struct FingerprintView: View {
|
||||
viewModel.unverifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text("REMOVE VERIFICATION")
|
||||
Text(Strings.removeVerification)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -8,10 +8,18 @@ struct GeohashPeopleList: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.visibleGeohashPeople().isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
Text(Strings.noneNearby)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
@@ -20,7 +28,7 @@ struct GeohashPeopleList: View {
|
||||
} else {
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
return nil
|
||||
@@ -53,7 +61,7 @@ struct GeohashPeopleList: View {
|
||||
let rowColor: Color = isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
|
||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
||||
let (base, suffix) = person.displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
@@ -66,7 +74,7 @@ struct GeohashPeopleList: View {
|
||||
.foregroundColor(suffixColor)
|
||||
}
|
||||
if isMe {
|
||||
Text(" (you)")
|
||||
Text(Strings.youSuffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(rowColor)
|
||||
}
|
||||
@@ -76,7 +84,7 @@ struct GeohashPeopleList: View {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help("Blocked in geochash")
|
||||
.help(Strings.blockedTooltip)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -97,9 +105,9 @@ struct GeohashPeopleList: View {
|
||||
} else {
|
||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||
if blocked {
|
||||
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
} else {
|
||||
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,16 +126,3 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to split a trailing #abcd suffix
|
||||
private func splitSuffix(from name: String) -> (String, 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, "")
|
||||
}
|
||||
|
||||
@@ -17,16 +17,72 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "location_channels.title"
|
||||
static let description: LocalizedStringKey = "location_channels.description"
|
||||
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
|
||||
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
|
||||
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
|
||||
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
|
||||
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||||
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
return rowTitle(label: label, count: count)
|
||||
}
|
||||
|
||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||
return rowTitle(label: level.displayName, count: count)
|
||||
}
|
||||
|
||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||
return rowTitle(label: "#\(geohash)", count: count)
|
||||
}
|
||||
|
||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
locale: .current,
|
||||
geohash, coverage
|
||||
)
|
||||
}
|
||||
|
||||
static func subtitle(prefix: String, name: String?) -> String {
|
||||
guard let name, !name.isEmpty else { return prefix }
|
||||
return String(
|
||||
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||
locale: .current,
|
||||
prefix, name
|
||||
)
|
||||
}
|
||||
|
||||
private static func rowTitle(label: String, count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||
locale: .current,
|
||||
label, count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Text("#location channels")
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
|
||||
Text(Strings.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -34,7 +90,7 @@ struct LocationChannelsSheet: View {
|
||||
switch manager.permissionState {
|
||||
case LocationChannelManager.PermissionState.notDetermined:
|
||||
Button(action: { manager.enableLocationChannels() }) {
|
||||
Text("get location and my geohashes")
|
||||
Text(Strings.requestPermissions)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -45,10 +101,10 @@ struct LocationChannelsSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("location permission denied. enable in settings to use location channels.")
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("open settings") { openSystemLocationSettings() }
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -107,188 +163,214 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
|
||||
// Nearby options
|
||||
if !manager.availableChannels.isEmpty {
|
||||
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
|
||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||
let nameBase = locationName(for: channel.level)
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||
let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||
if !nearby.isEmpty {
|
||||
ForEach(nearby) { channel in
|
||||
sectionDivider
|
||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||
let nameBase = locationName(for: channel.level)
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||||
let highlight = participantCount > 0
|
||||
channelRow(
|
||||
title: Strings.levelTitle(for: channel.level, count: participantCount),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
} else {
|
||||
sectionDivider
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNearby)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
sectionDivider
|
||||
customTeleportSection
|
||||
.padding(.vertical, 8)
|
||||
|
||||
let bookmarkedList = bookmarks.bookmarks
|
||||
if !bookmarkedList.isEmpty {
|
||||
sectionDivider
|
||||
bookmarkedSection(bookmarkedList)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 6)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private var sectionDivider: some View {
|
||||
Rectangle()
|
||||
.fill(dividerColor)
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private var dividerColor: Color {
|
||||
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
|
||||
}
|
||||
|
||||
private var customTeleportSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text(verbatim: "#")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = Strings.invalidGeohash; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
manager.markTeleported(for: ch.geohash, true)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text(Strings.teleport)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
let participantCount = viewModel.geohashParticipantCount(for: gh)
|
||||
channelRow(
|
||||
title: geohashTitleWithCount(for: channel),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("finding nearby channels…")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
}
|
||||
|
||||
// Custom geohash teleport
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text("#")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = "invalid geohash"; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
// Mark this selection as a manual teleport
|
||||
manager.markTeleported(for: ch.geohash, true)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text("teleport")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("bookmarked")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = "#\(gh) • \(coverage)"
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
channelRow(
|
||||
title: geohashHashTitleWithCount(gh),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
}
|
||||
if index < entries.count - 1 {
|
||||
sectionDivider
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
torToggleSection
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text("remove location access")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
|
||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
if case .location(let ch) = manager.selectedChannel {
|
||||
return ch == channel
|
||||
@@ -328,12 +410,7 @@ struct LocationChannelsSheet: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
}
|
||||
return subtitlePrefix
|
||||
}()
|
||||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||
Text(subtitleFull)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
@@ -342,7 +419,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text("✔︎")
|
||||
Text(verbatim: "✔︎")
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
@@ -362,36 +439,15 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
// MARK: - Helpers for counts
|
||||
private func meshTitleWithCount() -> String {
|
||||
// Count currently connected mesh peers (excluding self)
|
||||
let meshCount = meshCount()
|
||||
let noun = meshCount == 1 ? "person" : "people"
|
||||
return "mesh [\(meshCount) \(noun)]"
|
||||
}
|
||||
|
||||
private func meshCount() -> Int {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
||||
// Bookmarked list: show the #geohash as the main label
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "#\(geohash) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
@@ -424,21 +480,19 @@ extension LocationChannelsSheet {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: torToggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("tor routing")
|
||||
Text(Strings.torTitle)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Text("hides your ip for location channels. recommended: on.")
|
||||
Text(Strings.torSubtitle)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
||||
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private var standardGreen: Color {
|
||||
@@ -451,13 +505,15 @@ extension LocationChannelsSheet {
|
||||
|
||||
private struct IRCToggleStyle: ToggleStyle {
|
||||
let accent: Color
|
||||
let onLabel: LocalizedStringKey
|
||||
let offLabel: LocalizedStringKey
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: { configuration.isOn.toggle() }) {
|
||||
HStack(spacing: 12) {
|
||||
configuration.label
|
||||
Spacer()
|
||||
Text(configuration.isOn ? "on" : "off")
|
||||
Text(configuration.isOn ? onLabel : offLabel)
|
||||
.textCase(.uppercase)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||
|
||||
@@ -23,6 +23,21 @@ struct LocationNotesView: View {
|
||||
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
private enum Strings {
|
||||
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
@@ -85,14 +100,14 @@ struct LocationNotesView: View {
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
.accessibilityLabel(Strings.closeAccessibility)
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
let count = manager.notes.count
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text("#\(geohash) • \(count) \(count == 1 ? "note" : "notes")")
|
||||
Text(headerTitle(for: count))
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
@@ -106,16 +121,12 @@ struct LocationNotesView: View {
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(accentGreen)
|
||||
}
|
||||
Text("add short permanent notes to this location for other visitors to find.")
|
||||
Text(Strings.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .loading && !manager.initialLoadComplete {
|
||||
Text("loading recent notes…")
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
} else if manager.state == .noRelays {
|
||||
Text("geo relays unavailable; notes paused")
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -126,6 +137,14 @@ struct LocationNotesView: View {
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private func headerTitle(for count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||
locale: .current,
|
||||
geohash, count
|
||||
)
|
||||
}
|
||||
|
||||
private var notesContent: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
if manager.state == .noRelays {
|
||||
@@ -153,7 +172,7 @@ struct LocationNotesView: View {
|
||||
let ts = timestampText(for: note.createdAt)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("@\(baseName)")
|
||||
Text(verbatim: "@\(baseName)")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
if !ts.isEmpty {
|
||||
Text(ts)
|
||||
@@ -171,12 +190,12 @@ struct LocationNotesView: View {
|
||||
|
||||
private var noRelaysRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("no geo relays nearby")
|
||||
Text(Strings.noRelaysNearby)
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text("notes rely on geo relays. check connection and try again.")
|
||||
Text(Strings.relaysRetryHint)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("retry") { manager.refresh() }
|
||||
Button(Strings.retry) { manager.refresh() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -186,7 +205,7 @@ struct LocationNotesView: View {
|
||||
private var loadingRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("loading notes…")
|
||||
Text(Strings.loadingNotes)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
@@ -196,9 +215,9 @@ struct LocationNotesView: View {
|
||||
|
||||
private var emptyRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("no notes yet")
|
||||
Text(Strings.emptyTitle)
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text("be the first to add one for this spot.")
|
||||
Text(Strings.emptySubtitle)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -214,7 +233,7 @@ struct LocationNotesView: View {
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Spacer()
|
||||
}
|
||||
Button("dismiss") { manager.clearError() }
|
||||
Button(Strings.dismissError) { manager.clearError() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -223,7 +242,7 @@ struct LocationNotesView: View {
|
||||
|
||||
private var inputSection: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
||||
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
private typealias PlatformImage = UIImage
|
||||
#else
|
||||
import AppKit
|
||||
private typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
struct BlockRevealImageView: View {
|
||||
private let url: URL
|
||||
private let revealProgress: Double?
|
||||
private let isSending: Bool
|
||||
private let onCancel: (() -> Void)?
|
||||
private let initiallyBlurred: Bool
|
||||
private let onOpen: (() -> Void)?
|
||||
private let onDelete: (() -> Void)?
|
||||
|
||||
@State private var platformImage: PlatformImage?
|
||||
@State private var aspectRatio: CGFloat = 1
|
||||
@State private var isBlurred: Bool = false
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
revealProgress: Double?,
|
||||
isSending: Bool,
|
||||
onCancel: (() -> Void)?,
|
||||
initiallyBlurred: Bool = false,
|
||||
onOpen: (() -> Void)? = nil,
|
||||
onDelete: (() -> Void)? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.revealProgress = revealProgress
|
||||
self.isSending = isSending
|
||||
self.onCancel = onCancel
|
||||
self.initiallyBlurred = initiallyBlurred
|
||||
self.onOpen = onOpen
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
|
||||
private var fraction: Double {
|
||||
guard let revealProgress = revealProgress else { return 1 }
|
||||
return max(0, min(1, revealProgress))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if let image = platformImage {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.mask(
|
||||
BlockRevealMask(
|
||||
fraction: fraction,
|
||||
columns: 24,
|
||||
rows: 16
|
||||
)
|
||||
.animation(.easeOut(duration: 0.2), value: fraction)
|
||||
)
|
||||
.blur(radius: isBlurred ? 20 : 0)
|
||||
.overlay {
|
||||
if isBlurred {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.35))
|
||||
.overlay(
|
||||
Image(systemName: "eye.slash.fill")
|
||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(height: 200)
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
)
|
||||
}
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
Button(action: onCancel) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 12, weight: .bold))
|
||||
.padding(8)
|
||||
.background(Circle().fill(Color.black.opacity(0.7)))
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isBlurred = initiallyBlurred
|
||||
loadImage()
|
||||
}
|
||||
.onChange(of: url) { _ in
|
||||
isBlurred = initiallyBlurred
|
||||
loadImage()
|
||||
}
|
||||
.gesture(mainGesture)
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
#else
|
||||
guard let image = NSImage(contentsOf: url) else { return }
|
||||
#endif
|
||||
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||
DispatchQueue.main.async {
|
||||
self.platformImage = image
|
||||
self.aspectRatio = ratio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainGesture: some Gesture {
|
||||
let doubleTap = TapGesture(count: 2).onEnded {
|
||||
guard !isSending else { return }
|
||||
onDelete?()
|
||||
}
|
||||
let singleTap = TapGesture().onEnded {
|
||||
guard !isSending else { return }
|
||||
if isBlurred {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
isBlurred = false
|
||||
}
|
||||
} else {
|
||||
onOpen?()
|
||||
}
|
||||
}
|
||||
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
||||
guard !isSending else { return }
|
||||
let horizontal = value.translation.width
|
||||
let vertical = value.translation.height
|
||||
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
||||
if !isBlurred {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isBlurred = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockRevealMask: Shape {
|
||||
let fraction: Double
|
||||
let columns: Int
|
||||
let rows: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
guard fraction > 0, columns > 0, rows > 0 else { return path }
|
||||
let totalBlocks = columns * rows
|
||||
let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks)))))
|
||||
guard revealCount > 0 else { return path }
|
||||
let blockWidth = rect.width / CGFloat(columns)
|
||||
let blockHeight = rect.height / CGFloat(rows)
|
||||
var remaining = revealCount
|
||||
for row in 0..<rows {
|
||||
for column in 0..<columns {
|
||||
if remaining <= 0 { return path }
|
||||
let x = CGFloat(column) * blockWidth
|
||||
let y = CGFloat(row) * blockHeight
|
||||
path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))
|
||||
remaining -= 1
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private extension Image {
|
||||
init(platformImage: PlatformImage) {
|
||||
#if os(iOS)
|
||||
self.init(uiImage: platformImage)
|
||||
#else
|
||||
self.init(nsImage: platformImage)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FileAttachmentView: View {
|
||||
private let url: URL
|
||||
private let isSending: Bool
|
||||
private let progress: Double?
|
||||
private let onCancel: (() -> Void)?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
#if os(iOS)
|
||||
@State private var showExporter = false
|
||||
#endif
|
||||
|
||||
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
|
||||
self.url = url
|
||||
self.isSending = isSending
|
||||
self.progress = progress
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
private var fileName: String {
|
||||
url.lastPathComponent
|
||||
}
|
||||
|
||||
private var normalizedProgress: Double? {
|
||||
guard let progress = progress else { return nil }
|
||||
return max(0, min(1, progress))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "doc.fill")
|
||||
.foregroundColor(Color.blue)
|
||||
.font(.bitchatSystem(size: 24))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(fileName)
|
||||
.font(.bitchatSystem(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(2)
|
||||
Text(url.lastPathComponent)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if let progress = normalizedProgress {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Color.blue)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: openFile) {
|
||||
Text("open", comment: "Button to open attached file")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule().fill(Color.blue.opacity(0.15))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
Button(action: onCancel) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 11, weight: .bold))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showExporter) {
|
||||
FileExportController(url: url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openFile() {
|
||||
#if os(iOS)
|
||||
showExporter = true
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
private struct FileExportController: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||
controller.shouldShowFileExtensions = true
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
}
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct VoiceNoteView: View {
|
||||
private let url: URL
|
||||
private let isSending: Bool
|
||||
private let sendProgress: Double?
|
||||
private let onCancel: (() -> Void)?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@StateObject private var playback: VoiceNotePlaybackController
|
||||
@State private var waveform: [Float] = []
|
||||
|
||||
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
||||
self.url = url
|
||||
self.isSending = isSending
|
||||
self.sendProgress = sendProgress
|
||||
self.onCancel = onCancel
|
||||
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
||||
}
|
||||
|
||||
private var samples: [Float] {
|
||||
if waveform.isEmpty {
|
||||
return Array(repeating: 0.25, count: 64)
|
||||
}
|
||||
return waveform
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let duration = playback.duration
|
||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||
let minutes = Int(duration) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var currentText: String {
|
||||
let current = playback.currentTime
|
||||
guard current.isFinite, current > 0 else { return "00:00" }
|
||||
let minutes = Int(current) / 60
|
||||
let seconds = Int(current) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var playbackLabel: String {
|
||||
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: playback.togglePlayback) {
|
||||
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Color.green))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
WaveformView(
|
||||
samples: samples,
|
||||
playbackProgress: playback.progress,
|
||||
sendProgress: sendProgress,
|
||||
onSeek: { fraction in
|
||||
playback.seek(to: fraction)
|
||||
},
|
||||
isInteractive: playback.isPlaying
|
||||
)
|
||||
|
||||
Text(playbackLabel)
|
||||
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||
.foregroundColor(Color.secondary)
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
Button(action: onCancel) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 12, weight: .bold))
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(backgroundColor)
|
||||
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
.task {
|
||||
// Defer loading to let UI settle after view appears
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
||||
playback.loadDuration()
|
||||
await withCheckedContinuation { continuation in
|
||||
WaveformCache.shared.waveform(for: url, completion: { bins in
|
||||
waveform = bins
|
||||
continuation.resume()
|
||||
})
|
||||
}
|
||||
}
|
||||
.onChange(of: url) { newValue in
|
||||
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
||||
self.waveform = bins
|
||||
})
|
||||
playback.replaceURL(newValue)
|
||||
}
|
||||
.onDisappear {
|
||||
playback.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WaveformView: View {
|
||||
let samples: [Float]
|
||||
let playbackProgress: Double
|
||||
let sendProgress: Double?
|
||||
let onSeek: ((Double) -> Void)?
|
||||
let isInteractive: Bool
|
||||
|
||||
private var clampedPlayback: Double {
|
||||
max(0, min(1, playbackProgress))
|
||||
}
|
||||
|
||||
private var clampedSend: Double? {
|
||||
guard let sendProgress = sendProgress else { return nil }
|
||||
return max(0, min(1, sendProgress))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Canvas { context, size in
|
||||
guard !samples.isEmpty else { return }
|
||||
let width = max(size.width, 1)
|
||||
let height = max(size.height, 1)
|
||||
let barWidth = max(width / CGFloat(samples.count), 1)
|
||||
for (index, sample) in samples.enumerated() {
|
||||
let normalized = max(0, min(sample, 1))
|
||||
let barHeight = CGFloat(normalized) * height
|
||||
let originX = CGFloat(index) * barWidth
|
||||
let rect = CGRect(
|
||||
x: originX,
|
||||
y: (height - barHeight) / 2,
|
||||
width: max(barWidth * 0.7, 1),
|
||||
height: barHeight
|
||||
)
|
||||
let binPosition = Double(index) / Double(samples.count)
|
||||
let color: Color
|
||||
if binPosition <= clampedPlayback {
|
||||
color = Color.green
|
||||
} else if let send = clampedSend, binPosition <= send {
|
||||
color = Color.blue
|
||||
} else {
|
||||
color = Color.gray.opacity(0.35)
|
||||
}
|
||||
context.fill(Path(rect), with: .color(color))
|
||||
}
|
||||
}
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
|
||||
if isInteractive, let onSeek = onSeek {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onEnded { value in
|
||||
guard geometry.size.width > 0 else { return }
|
||||
let fraction = max(0, min(1, value.location.x / geometry.size.width))
|
||||
onSeek(fraction)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 48)
|
||||
}
|
||||
}
|
||||
@@ -11,37 +11,43 @@ struct MeshPeerList: View {
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
// Stable visual order without mutating state here
|
||||
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||
mapped.first(where: { $0.peer.peerID.id == id })
|
||||
}
|
||||
|
||||
if viewModel.allPeers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
Text(Strings.noneNearby)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
} else {
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.id == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
// Stable visual order without mutating state here
|
||||
let currentIDs = mapped.map { $0.peer.id }
|
||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||
mapped.first(where: { $0.peer.id == id })
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<peers.count, id: \.self) { idx in
|
||||
let item = peers[idx]
|
||||
let peer = item.peer
|
||||
let isMe = item.isMe
|
||||
HStack(spacing: 4) {
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill")
|
||||
@@ -70,7 +76,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
|
||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||
let (base, suffix) = splitSuffix(from: displayName)
|
||||
let (base, suffix) = displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
@@ -83,11 +89,11 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help("Blocked")
|
||||
.help(Strings.blockedTooltip)
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
@@ -99,7 +105,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
} else {
|
||||
// Offline: prefer showing verified badge from persisted fingerprints
|
||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@@ -120,11 +126,11 @@ struct MeshPeerList: View {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help("New messages")
|
||||
.help(Strings.newMessagesTooltip)
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.id) }) {
|
||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||
@@ -136,16 +142,16 @@ struct MeshPeerList: View {
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
.onAppear {
|
||||
let currentIDs = mapped.map { $0.peer.id }
|
||||
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||
orderedIDs = currentIDs
|
||||
}
|
||||
.onChange(of: mapped.map { $0.peer.id }) { ids in
|
||||
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
|
||||
var newOrder = orderedIDs
|
||||
newOrder.removeAll { !ids.contains($0) }
|
||||
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||
@@ -154,16 +160,3 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to split a trailing #abcd suffix
|
||||
private func splitSuffix(from name: String) -> (String, 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, "")
|
||||
}
|
||||
|
||||
@@ -37,15 +37,16 @@ extension String {
|
||||
}
|
||||
|
||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||
func extractCashuTokens(max: Int = 3) -> [String] {
|
||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||
let regex = RegexCache.cashu
|
||||
let ns = self as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
var found: [String] = []
|
||||
for m in regex.matches(in: self, options: [], range: range) {
|
||||
for m in regex.matches(in: self, range: range) {
|
||||
if m.numberOfRanges > 0 {
|
||||
let token = ns.substring(with: m.range(at: 0))
|
||||
found.append(token)
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||
found.append("cashu:\(enc)")
|
||||
if found.count >= max { break }
|
||||
}
|
||||
}
|
||||
@@ -58,19 +59,19 @@ extension String {
|
||||
let ns = self as NSString
|
||||
let full = NSRange(location: 0, length: ns.length)
|
||||
// lightning: scheme
|
||||
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
|
||||
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append(s)
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// BOLT11
|
||||
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
|
||||
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// LNURL bech32
|
||||
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
|
||||
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
|
||||
@@ -13,14 +13,19 @@ struct MyQRView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("scan to verify me")
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
QRCodeImage(data: qrString, size: 240)
|
||||
.accessibilityLabel("verification qr code")
|
||||
.accessibilityLabel(Strings.accessibilityLabel)
|
||||
|
||||
// Non-scrolling, fully visible URL (wraps across lines)
|
||||
Text(qrString)
|
||||
@@ -49,6 +54,10 @@ struct QRCodeImage: View {
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
|
||||
private enum Strings {
|
||||
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = generateImage() {
|
||||
@@ -59,7 +68,7 @@ struct QRCodeImage: View {
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text("qr unavailable")
|
||||
Text(Strings.unavailable)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
@@ -100,17 +109,41 @@ struct ImageWrapper: View {
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
var isActive: Bool = true
|
||||
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||
@State private var input = ""
|
||||
@State private var result: String = "" // not shown for iOS scanner
|
||||
@State private var lastValid: String = ""
|
||||
|
||||
private enum Strings {
|
||||
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||
static func requested(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
|
||||
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
#if os(iOS)
|
||||
CameraScannerView(isActive: isActive) { code in
|
||||
// Deduplicate: ignore if we just processed this exact QR code
|
||||
guard code != lastValid else { return }
|
||||
|
||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
if !ok { /* already pending; continue scanning */ }
|
||||
lastValid = code
|
||||
if ok {
|
||||
// Successfully initiated verification; remember this QR to prevent re-scanning
|
||||
lastValid = code
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
}
|
||||
// If !ok, peer not found or already pending - don't set lastValid so user can retry
|
||||
} else {
|
||||
// ignore invalid reads; continue scanning
|
||||
}
|
||||
@@ -118,17 +151,30 @@ struct QRScanView: View {
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#else
|
||||
Text("paste qr content to validate:")
|
||||
Text(Strings.pastePrompt)
|
||||
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
Button("validate") {
|
||||
Button(Strings.validate) {
|
||||
// Deduplicate: ignore if we just processed this exact QR
|
||||
guard input != lastValid else {
|
||||
result = Strings.requested("") // Already processed
|
||||
return
|
||||
}
|
||||
|
||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||
if ok {
|
||||
result = Strings.requested(qr.nickname)
|
||||
lastValid = input
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
} else {
|
||||
result = Strings.notFound
|
||||
}
|
||||
} else {
|
||||
result = "invalid or expired qr payload"
|
||||
result = Strings.invalid
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -246,7 +292,7 @@ struct VerificationSheetView: View {
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
private func myQRString() -> String {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
@@ -254,7 +300,7 @@ struct VerificationSheetView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("VERIFY")
|
||||
Text("verification.sheet.title")
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
@@ -278,18 +324,22 @@ struct VerificationSheetView: View {
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("scan a friend's qr")
|
||||
Text("verification.scan.prompt_friend")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
#if os(iOS)
|
||||
QRScanView(isActive: showingScanner)
|
||||
QRScanView(isActive: showingScanner, onSuccess: {
|
||||
showingScanner = false
|
||||
})
|
||||
.environmentObject(viewModel)
|
||||
.frame(height: 280)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
#else
|
||||
QRScanView()
|
||||
QRScanView(onSuccess: {
|
||||
showingScanner = false
|
||||
})
|
||||
.environmentObject(viewModel)
|
||||
#endif
|
||||
}
|
||||
@@ -323,7 +373,7 @@ struct VerificationSheetView: View {
|
||||
}
|
||||
|
||||
// Optional: Remove verification for selected peer (if verified)
|
||||
if let pid = viewModel.selectedPrivateChatPeer,
|
||||
if let pid = viewModel.selectedPrivateChatPeer?.id,
|
||||
let fp = viewModel.getFingerprint(for: pid),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// BitchatMessage+Preview.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
static var preview: BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: "John Doe",
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: "Jane Doe",
|
||||
senderPeerID: nil,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// PreviewKeychainManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
init() {}
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
func secureClear(_ data: inout Data) {}
|
||||
|
||||
func secureClear(_ string: inout String) {}
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,19 @@
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.assets.pictures.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"share.fallback.shared_link_title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "shared Link",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "رابط مشترك",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "geteilter link",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "enlace compartido",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "lien partagé",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "קישור משותף",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "tautan dibagikan",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "link condiviso",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "共有リンク",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "साझा गरिएको लिङ्क",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "link compartilhado",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "поделился ссылкой",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "спільне посилання",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "分享的链接",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "공유된 링크",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "paylaşılan bağlantı",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.failed_to_encode": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "failed to encode link",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "تعذر ترميز الرابط",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "link konnte nicht codiert werden",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "no se pudo codificar el enlace",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "échec de l'encodage du lien",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "לא ניתן לקודד את הקישור",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "gagal mengodekan tautan",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "impossibile codificare il link",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "リンクのエンコードに失敗しました",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "लिङ्क सङ्केत गर्न सकेन",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "falha ao codificar link",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "не удалось закодировать ссылку",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "не вдалося закодувати посилання",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "无法编码链接",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "링크를 인코딩하는 데 실패했습니다",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "bağlantı kodlanamadı",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.no_shareable_content": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "no shareable content",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "لا محتوى قابلاً للمشاركة",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "kein teilbarer inhalt",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "sin contenido que se pueda compartir",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "aucun contenu partageable",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "אין תוכן שניתן לשתף",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "tidak ada konten yang bisa dibagikan",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nessun contenuto condivisibile",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "共有可能なコンテンツがありません",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "बाँड्न मिल्ने सामग्री छैन",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nenhum conteúdo compartilhável",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нет подходящего контента",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нема відповідного контенту",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "没有可分享的素材",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "공유할 수 있는 내용이 없습니다",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "paylaşılabilir içerik yok",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.nothing_to_share": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nothing to share",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "لا شيء لمشاركته",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nichts zum teilen",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nada que compartir",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "rien à partager",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "אין מה לשתף",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "tidak ada yang bisa dibagikan",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "niente da condividere",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "共有できるものがありません",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "बाँड्ने केही छैन",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nada para compartilhar",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нечем поделиться",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нема чим ділитися",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "没有可分享的内容",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "공유할 내용이 없습니다",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "paylaşılacak bir şey yok",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.shared_link": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ shared link to bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ تم إرسال الرابط إلى bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ link zu bitchat geteilt",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ enlace compartido con bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ lien partagé vers bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ הקישור נשלח אל bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ tautan dikirim ke bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ link inviato a bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchatにリンクを共有",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat मा लिङ्क पठाइयो",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ link enviado para bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ ссылка отправлена в bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ посилання надіслано в bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ 已将链接分享至 bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat으로 링크를 공유했습니다",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat'e bağlantı paylaşıldı",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.shared_text": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ shared text to bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ تم إرسال النص إلى bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ text zu bitchat geteilt",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ texto compartido con bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ texte partagé vers bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ הטקסט נשלח אל bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ teks dikirim ke bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ testo inviato a bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchatにテキストを共有",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat मा पाठ पठाइयो",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ texto enviado para bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ текст отправлен в bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ текст надіслано в bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ 已将文本分享至 bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat'e metin paylaşıldı",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -14,6 +14,15 @@ import UniformTypeIdentifiers
|
||||
final class ShareViewController: UIViewController {
|
||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||
private static let groupID = "group.chat.bitchat"
|
||||
|
||||
private enum Strings {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
||||
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
||||
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
||||
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
||||
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
let l = UILabel()
|
||||
@@ -44,7 +53,7 @@ final class ShareViewController: UIViewController {
|
||||
private func processShare() {
|
||||
guard let ctx = self.extensionContext,
|
||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||
finishWithMessage("Nothing to share")
|
||||
finishWithMessage(Strings.nothingToShare)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +70,7 @@ final class ShareViewController: UIViewController {
|
||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
||||
saveAndFinish(text: title)
|
||||
} else {
|
||||
finishWithMessage("No shareable content")
|
||||
finishWithMessage(Strings.noShareableContent)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -81,7 +90,7 @@ final class ShareViewController: UIViewController {
|
||||
self.saveAndFinish(text: t)
|
||||
}
|
||||
} else {
|
||||
self.finishWithMessage("No shareable content")
|
||||
self.finishWithMessage(Strings.noShareableContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,20 +145,20 @@ final class ShareViewController: UIViewController {
|
||||
private func saveAndFinish(url: URL, title: String?) {
|
||||
let payload: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": title ?? url.host ?? "Shared Link"
|
||||
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||
]
|
||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let s = String(data: json, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: s, type: "url")
|
||||
finishWithMessage("✓ Shared link to bitchat")
|
||||
finishWithMessage(Strings.sharedLinkConfirmation)
|
||||
} else {
|
||||
finishWithMessage("Failed to encode link")
|
||||
finishWithMessage(Strings.failedToEncode)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndFinish(text: String) {
|
||||
saveToSharedDefaults(content: text, type: "text")
|
||||
finishWithMessage("✓ Shared text to bitchat")
|
||||
finishWithMessage(Strings.sharedTextConfirmation)
|
||||
}
|
||||
|
||||
private func saveToSharedDefaults(content: String, type: String) {
|
||||
|
||||
@@ -281,9 +281,12 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
||||
messageHandler(message)
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: String) {}
|
||||
func didDisconnectFromPeer(_ peerID: String) {}
|
||||
func didUpdatePeerList(_ peers: [String]) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func isFavorite(fingerprint: String) -> Bool { return false }
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||
}
|
||||
|
||||
@@ -6,115 +6,109 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import struct Foundation.UUID
|
||||
@testable import bitchat
|
||||
|
||||
final class PrivateChatE2ETests: XCTestCase {
|
||||
// TODO: Remove once MockBLEService is refactored to fix race condition
|
||||
@Suite(.serialized)
|
||||
struct PrivateChatE2ETests {
|
||||
|
||||
var alice: MockBluetoothMeshService!
|
||||
var bob: MockBluetoothMeshService!
|
||||
var charlie: MockBluetoothMeshService!
|
||||
private let alice: MockBLEService
|
||||
private let bob: MockBLEService
|
||||
private let charlie: MockBLEService
|
||||
private let mockKeychain: MockKeychain
|
||||
|
||||
private var mockKeychain: MockKeychain!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
MockBLEService.resetTestBus()
|
||||
|
||||
// Create services
|
||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
||||
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
|
||||
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
|
||||
init() {
|
||||
// Create services with unique peer IDs to avoid any collision
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||
mockKeychain = MockKeychain()
|
||||
|
||||
// Delivery tracking is now handled internally by BLEService
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
alice = nil
|
||||
bob = nil
|
||||
charlie = nil
|
||||
mockKeychain = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Basic Private Messaging Tests
|
||||
|
||||
func testSimplePrivateMessage() {
|
||||
simulateConnection(alice, bob)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Bob receives private message")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
expectation.fulfill()
|
||||
|
||||
@Test func simplePrivateMessageShouldNotBeSentWithoutConnection() async {
|
||||
// Intentionally not connecting alice and bob to test
|
||||
|
||||
var bobReceivedMessage = false
|
||||
|
||||
await confirmation("Bob should not receive a private message", expectedCount: 0) { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
bobReceivedMessage = true
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Bob
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
func testPrivateMessageNotReceivedByOthers() {
|
||||
// Connect all three
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(alice, charlie)
|
||||
|
||||
let bobExpectation = XCTestExpectation(description: "Bob receives private message")
|
||||
let charlieExpectation = XCTestExpectation(description: "Charlie should not receive")
|
||||
charlieExpectation.isInverted = true
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
||||
bobExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
charlieExpectation.fulfill() // Should not happen
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure connections are registered before send
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
// Alice sends private message to Bob only
|
||||
self.alice.sendPrivateMessage(
|
||||
|
||||
// Alice sends private message to Bob
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
to: bob.peerID,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
// Wait a bit to ensure message would have been delivered if it was going to be
|
||||
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
||||
}
|
||||
|
||||
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||
}
|
||||
|
||||
@Test func simplePrivateMessage() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Bob
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: bob.peerID,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
|
||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Delivery Acknowledgment Tests
|
||||
|
||||
// NOTE: DeliveryTracker has been removed in BLEService.
|
||||
// Delivery tracking is now handled internally.
|
||||
|
||||
|
||||
// MARK: - Message Retry Tests
|
||||
|
||||
// NOTE: MessageRetryService has been removed in BLEService.
|
||||
// Retry logic is now handled internally.
|
||||
|
||||
|
||||
@Test func privateMessageNotReceivedByOthers() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
alice.simulateConnection(with: charlie)
|
||||
|
||||
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
Issue.record("Charlie should not receive")
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: bob.peerID,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - End-to-End Encryption Tests
|
||||
|
||||
func testPrivateMessageEncryption() {
|
||||
simulateConnection(alice, bob)
|
||||
@Test func privateMessageEncryption() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
// Setup Noise sessions
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -125,175 +119,144 @@ final class PrivateChatE2ETests: XCTestCase {
|
||||
|
||||
// Establish encrypted session
|
||||
do {
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake3)
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
|
||||
} catch {
|
||||
XCTFail("Failed to establish Noise session: \(error)")
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
|
||||
let expectation = XCTestExpectation(description: "Encrypted message received")
|
||||
|
||||
// Setup packet handlers for encryption
|
||||
alice.packetDeliveryHandler = { packet in
|
||||
// Encrypt outgoing private messages
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
self.bob.simulateIncomingPacket(encryptedPacket)
|
||||
} catch {
|
||||
XCTFail("Encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
||||
if let message = BitchatMessage(decrypted) {
|
||||
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
||||
XCTAssertTrue(message.isPrivate)
|
||||
expectation.fulfill()
|
||||
await confirmation("Encrypted message received") { receiveEncryptedMessage in
|
||||
// Setup packet handlers for encryption
|
||||
alice.packetDeliveryHandler = { packet in
|
||||
// Encrypt outgoing private messages
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
self.bob.simulateIncomingPacket(encryptedPacket)
|
||||
} catch {
|
||||
Issue.record("Encryption failed: \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Decryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send encrypted private message
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Multi-hop Private Message Tests
|
||||
|
||||
func testPrivateMessageRelay() {
|
||||
// Setup: Alice -> Bob -> Charlie
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Private message relayed to Charlie")
|
||||
|
||||
// Bob relays private messages for Charlie
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
if let recipientID = packet.recipientID,
|
||||
String(data: recipientID, encoding: .utf8) == TestConstants.testPeerID3 {
|
||||
// Relay to Charlie
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.recipientNickname == TestConstants.testNickname3 {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Charlie (through Bob)
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID3,
|
||||
recipientNickname: TestConstants.testNickname3
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
func testPrivateMessageThroughput() {
|
||||
simulateConnection(alice, bob)
|
||||
|
||||
let messageCount = 100
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "All private messages received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.sender == TestConstants.testNickname1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == messageCount {
|
||||
expectation.fulfill()
|
||||
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||
if let message = BitchatMessage(decrypted) {
|
||||
#expect(message.content == TestConstants.testMessage1)
|
||||
#expect(message.isPrivate)
|
||||
receiveEncryptedMessage()
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Decryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send many private messages
|
||||
for i in 0..<messageCount {
|
||||
|
||||
// Send encrypted private message
|
||||
alice.sendPrivateMessage(
|
||||
"Private message \(i)",
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(receivedCount, messageCount)
|
||||
}
|
||||
|
||||
func testLargePrivateMessage() {
|
||||
simulateConnection(alice, bob)
|
||||
// MARK: - Multi-hop Private Message Tests
|
||||
|
||||
@Test func privateMessageRelay() async {
|
||||
// Setup: Alice -> Bob -> Charlie
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Large private message received")
|
||||
await confirmation("Private message relayed to Charlie") { charlieReceivesMessage in
|
||||
// Bob relays private messages for Charlie
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
if let recipientID = packet.recipientID,
|
||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||
// Relay to Charlie
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.recipientNickname == TestConstants.testNickname3 {
|
||||
charlieReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Charlie (through Bob)
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: charlie.peerID,
|
||||
recipientNickname: TestConstants.testNickname3
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
@Test func privateMessageThroughput() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
||||
expectation.fulfill()
|
||||
let messageCount = 100
|
||||
var receivedCount = 0
|
||||
|
||||
await confirmation("All private messages received") { receivePrivateMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.sender == TestConstants.testNickname1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == messageCount {
|
||||
receivePrivateMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send many private messages
|
||||
for i in 0..<messageCount {
|
||||
alice.sendPrivateMessage(
|
||||
"Private message \(i)",
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testLongMessage,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Error Handling Tests
|
||||
|
||||
// NOTE: This test relied on MessageRetryService which has been removed
|
||||
|
||||
func testDuplicateAckPrevention() throws {
|
||||
throw XCTSkip("DeliveryTracker/ACK flow removed; test not applicable")
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
||||
let service = MockBluetoothMeshService()
|
||||
service.myPeerID = peerID
|
||||
service.mockNickname = nickname
|
||||
service._testRegister()
|
||||
return service
|
||||
}
|
||||
|
||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
||||
peer1.simulateConnectedPeer(peer2.peerID)
|
||||
peer2.simulateConnectedPeer(peer1.peerID)
|
||||
|
||||
@Test func largePrivateMessage() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
await confirmation("Large private message received") { receiveLargeMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
||||
receiveLargeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testLongMessage,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,440 +6,389 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Testing
|
||||
import struct Foundation.UUID
|
||||
@testable import bitchat
|
||||
|
||||
final class PublicChatE2ETests: XCTestCase {
|
||||
@Suite(.serialized)
|
||||
struct PublicChatE2ETests {
|
||||
|
||||
var alice: MockBluetoothMeshService!
|
||||
var bob: MockBluetoothMeshService!
|
||||
var charlie: MockBluetoothMeshService!
|
||||
var david: MockBluetoothMeshService!
|
||||
private let alice: MockBLEService
|
||||
private let bob: MockBLEService
|
||||
private let charlie: MockBLEService
|
||||
private let david: MockBLEService
|
||||
|
||||
var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
MockBLEService.resetTestBus()
|
||||
|
||||
// Create mock services
|
||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
||||
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
|
||||
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
|
||||
david = createMockService(peerID: TestConstants.testPeerID4, nickname: TestConstants.testNickname4)
|
||||
|
||||
// Clear received messages
|
||||
receivedMessages.removeAll()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
alice = nil
|
||||
bob = nil
|
||||
charlie = nil
|
||||
david = nil
|
||||
super.tearDown()
|
||||
init() {
|
||||
// Create mock services with unique peer IDs to avoid any collision
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
||||
}
|
||||
|
||||
// MARK: - Basic Broadcasting Tests
|
||||
|
||||
func testSimplePublicMessage() {
|
||||
// Connect Alice and Bob
|
||||
simulateConnection(alice, bob)
|
||||
@Test func simplePublicMessage() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Bob receives message")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
||||
expectation.fulfill()
|
||||
await confirmation("Bob receives message") { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice sends public message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
func testMultiRecipientBroadcast() {
|
||||
// Connect Alice to Bob and Charlie
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(alice, charlie)
|
||||
@Test func multiRecipientBroadcast() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
alice.simulateConnection(with: charlie)
|
||||
|
||||
let bobExpectation = XCTestExpectation(description: "Bob receives message")
|
||||
let charlieExpectation = XCTestExpectation(description: "Charlie receives message")
|
||||
var bobReceivedMessage = false
|
||||
var charlieReceivedMessage = false
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
bobExpectation.fulfill()
|
||||
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
if !bobReceivedMessage {
|
||||
bobReceivedMessage = true
|
||||
receiveMessage()
|
||||
} else {
|
||||
Issue.record("Bob received more than once")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
charlieExpectation.fulfill()
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
if !charlieReceivedMessage {
|
||||
charlieReceivedMessage = true
|
||||
receiveMessage()
|
||||
} else {
|
||||
Issue.record("Charlie received more than once")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Message Routing and Relay Tests
|
||||
|
||||
func testMessageRelayChain() {
|
||||
@Test func messageRelayChain() async {
|
||||
// Linear topology: Alice -> Bob -> Charlie
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Charlie receives relayed message")
|
||||
|
||||
// Set up relay in Bob
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Bob should relay to Charlie
|
||||
if let message = BitchatMessage(packet.payload),
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
|
||||
await confirmation("Charlie receives relayed message") { charlieReceivesMessage in
|
||||
// Set up relay in Bob
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Bob should relay to Charlie
|
||||
if let message = BitchatMessage(packet.payload),
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
// Simulate relay to Charlie
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
)
|
||||
|
||||
// Simulate relay to Charlie
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
expectation.fulfill()
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
charlieReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice sends message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testMultiHopRelay() {
|
||||
@Test func multiHopRelay() async {
|
||||
// Topology: Alice -> Bob -> Charlie -> David
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
simulateConnection(charlie, david)
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
charlie.simulateConnection(with: david)
|
||||
|
||||
let expectation = XCTestExpectation(description: "David receives multi-hop message")
|
||||
|
||||
// Set up relay chain
|
||||
setupRelayHandler(bob, nextHops: [charlie])
|
||||
setupRelayHandler(charlie, nextHops: [david])
|
||||
// Allow handlers to install
|
||||
let sendDelay = DispatchTime.now() + 0.05
|
||||
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
expectation.fulfill()
|
||||
await confirmation("David receives multi-hop message") { davidReceivesMessage in
|
||||
// Set up relay chain
|
||||
setupRelayHandler(bob, nextHops: [charlie])
|
||||
setupRelayHandler(charlie, nextHops: [david])
|
||||
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
davidReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice sends message
|
||||
DispatchQueue.main.asyncAfter(deadline: sendDelay) {
|
||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - TTL (Time To Live) Tests
|
||||
|
||||
func testTTLDecrement() {
|
||||
@Test func ttlDecrement() async {
|
||||
// Create a chain longer than TTL
|
||||
let nodes = [alice!, bob!, charlie!, david!]
|
||||
let nodes = [alice, bob, charlie, david]
|
||||
|
||||
// Connect in chain
|
||||
for i in 0..<nodes.count-1 {
|
||||
simulateConnection(nodes[i], nodes[i+1])
|
||||
nodes[i].simulateConnection(with: nodes[i+1])
|
||||
if i > 0 && i < nodes.count-1 {
|
||||
setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])
|
||||
}
|
||||
}
|
||||
|
||||
let expectation = XCTestExpectation(description: "Message dropped due to TTL")
|
||||
expectation.isInverted = true // Should NOT be fulfilled
|
||||
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
expectation.fulfill() // This should not happen
|
||||
await confirmation("Message dropped due to TTL", expectedCount: 0) { receiveMessage in
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage() // This should not happen
|
||||
}
|
||||
}
|
||||
|
||||
// Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David
|
||||
let msg = TestHelpers.createTestMessage(
|
||||
content: TestConstants.testMessage1,
|
||||
sender: TestConstants.testNickname1,
|
||||
senderPeerID: alice.peerID
|
||||
)
|
||||
|
||||
if let payload = msg.toBinaryPayload() {
|
||||
let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
|
||||
bob.simulateIncomingPacket(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David
|
||||
let msg = TestHelpers.createTestMessage(content: TestConstants.testMessage1, sender: TestConstants.testNickname1, senderPeerID: alice.peerID)
|
||||
if let payload = msg.toBinaryPayload() {
|
||||
let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
|
||||
bob.simulateIncomingPacket(pkt)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
func testZeroTTLNotRelayed() {
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
@Test func zeroTTLNotRelayed() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Zero TTL message not relayed")
|
||||
expectation.isInverted = true
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == "Zero TTL message" {
|
||||
expectation.fulfill() // Should not happen
|
||||
await confirmation("Zero TTL message not relayed", expectedCount: 0) { receiveMessage in
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == "Zero TTL message" {
|
||||
receiveMessage() // Should not happen
|
||||
}
|
||||
}
|
||||
|
||||
// Create packet with TTL=0
|
||||
let message = TestHelpers.createTestMessage(content: "Zero TTL message")
|
||||
if let payload = message.toBinaryPayload() {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)
|
||||
alice.simulateIncomingPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Create packet with TTL=0
|
||||
let message = TestHelpers.createTestMessage(content: "Zero TTL message")
|
||||
if let payload = message.toBinaryPayload() {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)
|
||||
alice.simulateIncomingPacket(packet)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Duplicate Detection Tests
|
||||
|
||||
func testDuplicateMessagePrevention() {
|
||||
simulateConnection(alice, bob)
|
||||
@Test func duplicateMessagePrevention() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
var messageCount = 0
|
||||
let expectation = XCTestExpectation(description: "Only one message received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
messageCount += 1
|
||||
if messageCount == 1 {
|
||||
// Send duplicate after small delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
||||
await confirmation("Only one message received") { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
messageCount += 1
|
||||
if messageCount == 1 {
|
||||
// Send duplicate after small delay
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
||||
} else {
|
||||
Issue.record("Duplicate message was not filtered")
|
||||
}
|
||||
} else {
|
||||
XCTFail("Duplicate message was not filtered")
|
||||
}
|
||||
}
|
||||
|
||||
// Send original message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func duplicateContentAsNewMessageNotPrevented() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
// Send original message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
var messageCount = 0
|
||||
|
||||
// Wait to ensure duplicate would have been received
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(messageCount, 1)
|
||||
expectation.fulfill()
|
||||
await confirmation("Only one message received", expectedCount: 2) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
messageCount += 1
|
||||
if messageCount == 1 {
|
||||
// Send the same content as a new message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send original message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Mention Tests
|
||||
|
||||
func testMessageWithMentions() {
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(alice, charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Mentioned users receive notification")
|
||||
@Test func messageWithMentions() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
alice.simulateConnection(with: charlie)
|
||||
|
||||
var mentionedUsers: Set<String> = []
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname2) {
|
||||
mentionedUsers.insert(TestConstants.testNickname2)
|
||||
await confirmation("Mentioned users receive notification", expectedCount: 2) { receiveMention in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.mentions?.contains(TestConstants.testNickname2) == true {
|
||||
mentionedUsers.insert(TestConstants.testNickname2)
|
||||
receiveMention()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname3) {
|
||||
mentionedUsers.insert(TestConstants.testNickname3)
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.mentions?.contains(TestConstants.testNickname3) == true {
|
||||
mentionedUsers.insert(TestConstants.testNickname3)
|
||||
receiveMention()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice mentions Bob and Charlie
|
||||
alice.sendMessage(
|
||||
"Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!",
|
||||
mentions: [TestConstants.testNickname2, TestConstants.testNickname3],
|
||||
to: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Alice mentions Bob and Charlie
|
||||
alice.sendMessage(
|
||||
"Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!",
|
||||
mentions: [TestConstants.testNickname2, TestConstants.testNickname3],
|
||||
to: nil
|
||||
)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(mentionedUsers, [TestConstants.testNickname2, TestConstants.testNickname3])
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
#expect(mentionedUsers == [TestConstants.testNickname2, TestConstants.testNickname3])
|
||||
}
|
||||
|
||||
// MARK: - Network Topology Tests
|
||||
|
||||
func testMeshTopologyBroadcast() {
|
||||
@Test func meshTopologyBroadcast() async {
|
||||
// Create mesh: Everyone connected to everyone
|
||||
let nodes = [alice!, bob!, charlie!, david!]
|
||||
let nodes = [alice, bob, charlie, david]
|
||||
for i in 0..<nodes.count {
|
||||
for j in i+1..<nodes.count {
|
||||
simulateConnection(nodes[i], nodes[j])
|
||||
nodes[i].simulateConnection(with: nodes[j])
|
||||
}
|
||||
}
|
||||
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "All nodes receive message")
|
||||
|
||||
for (index, node) in nodes.enumerated() where index > 0 {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == 3 { // Bob, Charlie, David
|
||||
expectation.fulfill()
|
||||
await confirmation("All nodes receive message", expectedCount: 3) { receiveMessage in
|
||||
for (index, node) in nodes.enumerated() where index > 0 {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testPartialMeshRelay() {
|
||||
@Test func partialMeshRelay() async {
|
||||
// Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
simulateConnection(charlie, david)
|
||||
simulateConnection(david, alice)
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
charlie.simulateConnection(with: david)
|
||||
david.simulateConnection(with: alice)
|
||||
|
||||
// Setup relay handlers
|
||||
setupRelayHandler(bob, nextHops: [charlie])
|
||||
setupRelayHandler(charlie, nextHops: [david])
|
||||
setupRelayHandler(david, nextHops: [alice])
|
||||
let sendDelay2 = DispatchTime.now() + 0.05
|
||||
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "Message reaches all nodes once")
|
||||
|
||||
let checkCompletion = {
|
||||
if receivedCount == 3 { // Bob, Charlie, David should receive
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
for node in [bob!, charlie!, david!] {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receivedCount += 1
|
||||
checkCompletion()
|
||||
await confirmation("Message reaches all nodes once", expectedCount: 3) { receiveMessage in
|
||||
for node in [bob, charlie, david] {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
DispatchQueue.main.asyncAfter(deadline: sendDelay2) {
|
||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Performance and Stress Tests
|
||||
|
||||
func testHighVolumeMessaging() {
|
||||
simulateConnection(alice, bob)
|
||||
@Test func highVolumeMessaging() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
let messageCount = 100
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "All messages received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.sender == TestConstants.testNickname1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == messageCount {
|
||||
expectation.fulfill()
|
||||
await confirmation("All messages received", expectedCount: messageCount) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.sender == TestConstants.testNickname1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send many messages rapidly
|
||||
for i in 0..<messageCount {
|
||||
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(receivedCount, messageCount)
|
||||
}
|
||||
|
||||
func testLargeMessageBroadcast() {
|
||||
simulateConnection(alice, bob)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Large message received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage {
|
||||
expectation.fulfill()
|
||||
|
||||
// Send many messages rapidly
|
||||
for i in 0..<messageCount {
|
||||
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func largeMessageBroadcast() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
// Send large message
|
||||
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
await confirmation("Large message received") { receiveLargeMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage {
|
||||
receiveLargeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
||||
let service = MockBluetoothMeshService()
|
||||
service.myPeerID = peerID
|
||||
service.mockNickname = nickname
|
||||
service._testRegister()
|
||||
return service
|
||||
}
|
||||
|
||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
||||
peer1.simulateConnectedPeer(peer2.peerID)
|
||||
peer2.simulateConnectedPeer(peer1.peerID)
|
||||
}
|
||||
|
||||
private func setupRelayHandler(_ node: MockBluetoothMeshService, nextHops: [MockBluetoothMeshService]) {
|
||||
|
||||
private func setupRelayHandler(_ node: MockBLEService, nextHops: [MockBLEService]) {
|
||||
node.packetDeliveryHandler = { packet in
|
||||
// Check if should relay
|
||||
guard packet.ttl > 1 else { return }
|
||||
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
// Don't relay own messages
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
guard message.senderPeerID?.id != node.peerID else { return }
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
@@ -458,7 +407,7 @@ final class PublicChatE2ETests: XCTestCase {
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: node.peerID.data(using: .utf8)!,
|
||||
senderID: node.peerID.id.data(using: .utf8)!,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
|
||||
@@ -3,14 +3,14 @@ import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class FontBitchatTests: XCTestCase {
|
||||
func testMonospacedMapping() {
|
||||
XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
|
||||
XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
|
||||
XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
|
||||
}
|
||||
|
||||
func testWeightIsPreserved() {
|
||||
let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
|
||||
}
|
||||
// func testMonospacedMapping() {
|
||||
// XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
|
||||
// XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
|
||||
// XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
|
||||
// }
|
||||
//
|
||||
// func testWeightIsPreserved() {
|
||||
// let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
// XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
|
||||
// }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user