Contacts Module — Overview¶
Architecture¶
Modules/Contacts/
├── Models/
│ ├── ContactModel.swift # Contact struct (id, contactName, isFavorite, lastInteraction)
│ ├── FriendRequest.swift # FriendRequest struct with date formatting
│ └── FriendAddMode.swift # Enum: scan | autoImport
├── Services/
│ └── ContactsAPIService.swift # Singleton — all API calls
├── ViewModels/
│ ├── ContactsViewModel.swift # Shared state: contacts list + badge count
│ ├── AddFriendViewModel.swift # QR parsing, token validation, send request
│ └── FriendRequestsViewModel.swift
└── Views/
├── ContactsView.swift # Main tab view with search + list
├── ContactsListView.swift # Favorites / All sections
├── ContactRow.swift # Single contact row with call buttons
├── AddFriendView.swift # Add friend modal (scan/import modes)
├── QRScannerView.swift # AVFoundation camera scanner
├── FriendRequestsView.swift # Received + Sent tabs
├── ReceivedRequestRow.swift
├── SentRequestRow.swift
├── SelectContactListView.swift
├── EmptyStateView.swift
└── ImagePicker.swift # Photo library QR import
API Endpoints¶
| Action | Method | Endpoint |
|---|---|---|
| Get contacts | GET | /contacts |
| Get contact by ID | GET | /contacts?contactId={id} |
| Toggle favorite | PATCH | /contact/{id}/toggle |
| Delete contact | DELETE | /contact/{id} |
| Send request by ID | POST | /friend-request/by-id |
| Send request by token | POST | /friend-request/by-token |
| Accept friend request | POST | /friend-request/accept |
| Decline friend request | PATCH | /friend-request/decline |
| Cancel friend request | PATCH | /friend-request/cancel |
| Get received requests | GET | /friend-request/received |
| Get sent requests | GET | /friend-request/sent |
| Generate share token | POST | /app/api/share-token |
| Resolve share token | POST | /app/api/share-token/resolve |
| Revoke share token | DELETE | /app/api/share-token |
All POST/PATCH bodies use { "targetUserId": <UInt64> }.
Share-token endpoints use JSON body; see DTOs below.
Friend Request Flow¶
┌─────────┐ Send Request ┌──────────┐
│ User A │ ──────────────► │ User B │
│ (sender) │ │(receiver) │
└─────────┘ └──────────┘
│ │
│ POST /friend-request/by-id
│ or
│ POST /friend-request/by-token
│ │
│ Push notification (.newFriendRequested)
│ │
│ ┌─────────▼──────────┐
│ │ Accept / Decline │
│ └─────────┬──────────┘
│ │
│ POST /friend-request/accept
│ or PATCH /friend-request/decline
│ │
│ Push notification (.acceptFriendRequested)
│ │
▼ ▼
Both users' contact lists reload automatically
Notification Events¶
| Notification | Trigger | Action |
|---|---|---|
.newFriendRequested | Push: someone sent you a request | Navigate to FriendRequestsView, reload badge count |
.acceptFriendRequested | Push: your request was accepted | Reload contacts list + badge count |
.sentNewFriendRequested | Local: you sent a new request | (Internal, posted by AddFriendViewModel) |
Badge Count¶
ContactsViewModel.friendRequestReceivedCount drives the red badge on the Contacts tab.
Ownership pattern: @StateObject in MainTabView → @ObservedObject in ContactsView. Single source of truth — badge and list always in sync.
QR Code Share Contact Flow¶
⚠️ Architecture updated (2026-04-08): Token generation moved from client-side AES-GCM to server-generated opaque tokens. See deeplink-token-migration.md for full rationale.
Generation — My QR Code (MyQRCodeViewModel)¶
User opens QR popup (Settings / Contacts)
│
▼
MyQRCodeViewModel.init()
└── loadCredentials() → reads userId, displayName from KeyStorage
View.onAppear:
└── Task { await viewModel.requestAndGenerateQR() }
│
▼
POST /app/api/share-token ← bearer token auth
│ Backend:
│ ├── Rate limit: 20 tokens/hour/user
│ ├── crypto/rand 32 bytes → base62 (43 chars)
│ └── INSERT share_tokens (TTL: 24 hours, Max Uses: 1)
▼
{ token: "a8KxR3mFvL2pNqW7..." }
│
▼
deepLink = AppEnvironment.shareContactURL(token:)
→ https://{domain}/share/contact/{token}
│
▼
generateQRCode(deepLink) → UIImage (CoreImage/QR)
State properties: - isLoadingToken: Bool — shows ProgressView while API is in-flight - tokenError: String? — shows retry button on failure - cachedDeepLink: String — the current deep link being rendered as QR
Token config:
| Parameter | Value |
|---|---|
| TTL | 24 hours |
| Max uses | 1 (one-time) |
| Active per user | Unlimited (independent tokens) |
| Rate limit | 20/hour |
Scanning — Add Friend (AddFriendViewModel)¶
Scan QR / Import Photo / Universal Link / Deep Link
│
▼
validateAndSetQRPayload(scannedString)
│
├── Extract token from URL
│ ├── Universal link: https://{domain}/share/contact/{token}
│ ├── Deep link: ecall://contact/{token}
│ └── Numeric: use raw string as userId directly
│
├── UInt64(token) != nil?
│ YES → Numeric userId (e.g. Call History)
│ Use `POST /app/api/friend-request/by-id` directly
│
└── NO → Opaque Server Token
scannedQRPayload = url
Task { await resolveServerToken(url) }
│
▼
POST /app/api/share-token/resolve { token }
│ Backend validates:
│ ├── exists + not expired (within 24h)
│ ├── used_count < max_uses
│ └── resolver ≠ creator (self-resolve guard)
│ (Note: Token is NOT burned here)
▼
{ token: String, displayName: String }
→ resolvedContact set → token/displayName computed props update
*Note: When sending the friend request, if resolvedContact provides a token, we use `POST /app/api/friend-request/by-token`. If we only have a numeric userId, we use `POST /app/api/friend-request/by-id`.*
Resolution state properties: - isResolvingToken: Bool — loading indicator while API resolves - resolvedContact: ResolvedContact? — nil until server responds
Computed props priority (userId / displayName): 1. resolvedContact (server-resolved — highest priority) 2. importedDisplayName (e.g. from Call History swipe action)
Add Contact Input Sources¶
| Source | Property | Mode | Token Path |
|---|---|---|---|
| Camera QR scan | scannedQRPayload | .scan | Server resolve |
| Photo library import | selectedImage → processSelectedImage() → scannedQRPayload | .scan | Server resolve |
Universal link (/share/contact/{token}) | importedQRPayload | .autoImport | Server resolve triggered in ecallApp.onOpenURL |
| Call history swipe action | importedQRPayload + importedDisplayName | .autoImport | Numeric userId, no resolve needed |
Deeplink Handler (ecallApp.swift)¶
.onOpenURL { url in
guard AppUtils.validUrlApp(url) else { return }
addFriendVM.importedQRPayload = url.absoluteString
showAddFriendSheet = true
// Resolve server tokens async
Task { await addFriendVM.resolveServerToken(url.absoluteString) }
}
Server Token Error Handling¶
| HTTP | Error Code | UI Action |
|---|---|---|
| 404 | ErrTokenNotFound | Show "Invalid link" error |
| 410 | ErrTokenExpired | Show "Link expired" error |
| 410 | ErrTokenUsed | Show "Link used" error |
| 409 | ErrSelfResolve | (Should not occur — guard on server) |
Token Architecture¶
Current (Server-Generated Opaque Token)¶
| Property | Value |
|---|---|
| Generation | crypto/rand 32 bytes → base62 encode (43 chars) |
| Storage | Server DB (share_tokens table) |
| Client exposure | Token string only — no crypto key on client |
| TTL | 24 hours |
| Uses | One-time (max_uses = 1) |
| Forgery | Impossible (no key on client) |
DTOs¶
struct ShareTokenResponse: Codable {
let token: String
}
struct ResolveTokenRequest: Encodable {
let token: String
}
struct ResolvedContact: Codable {
let token: String
let displayName: String
}
Key Files¶
| File | Purpose |
|---|---|
ContactsAPIService.swift | All API endpoints incl. generateShareToken(), resolveShareToken() |
ContactsViewModel.swift | Shared state — contacts list, search, badge count |
AddFriendViewModel.swift | Token format detection, server resolve, send request |
MyQRCodeViewModel.swift | Async server token generation, QR image rendering, loading/error states |
QRScannerView.swift | AVFoundation camera with dynamic orientation (iPad) |
ContactModel.swift | Contact structs + ShareTokenResponse, ResolveTokenRequest, ResolvedContact DTOs |
APIEndpoint.swift | Endpoint enum including .shareToken, .shareTokenResolve cases |