Skip to content

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 selectedImageprocessSelectedImage()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

.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