Showing
1 changed file
with
1162 additions
and
0 deletions
skill.md
0 → 100644
| 1 | +# SwiftWarplyFramework — AI Agent Skill File | ||
| 2 | + | ||
| 3 | +> Comprehensive knowledge base for AI coding agents (Claude Code, Gemini CLI, Codex, OpenCode) working on the SwiftWarplyFramework iOS SDK. | ||
| 4 | + | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +## 1. Project Overview | ||
| 8 | + | ||
| 9 | +**SwiftWarplyFramework** is a native iOS loyalty/rewards SDK (version 2.3.0) built in Swift for the DEI (Public Power Corporation of Greece) engagement platform. It provides a complete loyalty program toolkit including campaigns, coupons, merchant discovery, user profiles, card management, transaction history, and market pass (supermarket deals) features. | ||
| 10 | + | ||
| 11 | +- **Language:** Swift 5.9+ | ||
| 12 | +- **Minimum iOS:** 17.0 | ||
| 13 | +- **Repository:** `https://git.warp.ly/open-source/warply_sdk_framework.git` | ||
| 14 | +- **Framework Type:** Distributable iOS framework (CocoaPods + SPM) | ||
| 15 | +- **Entry Point:** `WarplySDK.shared` (singleton) | ||
| 16 | +- **Backend:** RESTful API on `engage-uat.dei.gr` (UAT) / `engage-prod.dei.gr` (production) | ||
| 17 | +- **Auth Model:** JWT tokens (access + refresh) stored in local SQLite database, automatic refresh with circuit breaker | ||
| 18 | + | ||
| 19 | +### What This SDK Does | ||
| 20 | +- Authenticates users via DEI login (email-based) or Cosmote partner flow (GUID-based) | ||
| 21 | +- Manages loyalty campaigns, coupons, coupon sets, and personalized offers | ||
| 22 | +- Handles card management (add/get/delete credit cards) | ||
| 23 | +- Provides transaction and points history | ||
| 24 | +- Manages supermarket/market pass deals with map integration | ||
| 25 | +- Sends analytics events (Dynatrace integration) | ||
| 26 | +- Provides pre-built UI screens (MyRewards, Profile, Coupon, Campaign WebView) | ||
| 27 | + | ||
| 28 | +## 2. Architecture & Design Patterns | ||
| 29 | + | ||
| 30 | +### Core Patterns | ||
| 31 | + | ||
| 32 | +| Pattern | Where Used | Details | | ||
| 33 | +|---------|-----------|---------| | ||
| 34 | +| **Singleton** | `WarplySDK.shared`, `NetworkService.shared`, `DatabaseManager.shared`, `EventDispatcher.shared` | Main entry points for all subsystems | | ||
| 35 | +| **Actor Isolation** | `TokenRefreshManager`, `FieldEncryption`, `KeychainManager`, `TokenRefreshCircuitBreaker`, `RequestQueue` | Thread-safe concurrent access for security-critical components | | ||
| 36 | +| **Enum-based Routing** | `Endpoint` enum | All API endpoints defined as enum cases with computed properties for path, method, parameters, auth type | | ||
| 37 | +| **Dual API Surface** | All public SDK methods | Every API has both callback-based and `async/await` variants | | ||
| 38 | +| **Event Bus (Dual)** | `SwiftEventBus` + `EventDispatcher` | Events posted to both systems for backward compatibility | | ||
| 39 | +| **PropertyWrapper** | `@UserDefault<T>` | Type-safe UserDefaults access | | ||
| 40 | +| **Circuit Breaker** | `TokenRefreshCircuitBreaker` | Prevents excessive token refresh attempts | | ||
| 41 | +| **Builder/Factory** | `WarplyConfiguration` presets | `.development`, `.production`, `.testing`, `.highSecurity` | | ||
| 42 | + | ||
| 43 | +### Dependency Flow | ||
| 44 | +``` | ||
| 45 | +WarplySDK (public API facade) | ||
| 46 | + ├── NetworkService (HTTP requests) | ||
| 47 | + │ ├── Endpoint (URL/param/auth routing) | ||
| 48 | + │ └── TokenRefreshManager (actor, JWT refresh) | ||
| 49 | + │ └── DatabaseManager (token storage) | ||
| 50 | + ├── DatabaseManager (SQLite.swift) | ||
| 51 | + │ └── FieldEncryption (actor, AES-256-GCM) | ||
| 52 | + │ └── KeychainManager (actor, hardware-backed keys) | ||
| 53 | + ├── EventDispatcher (modern event system) | ||
| 54 | + ├── SDKState (in-memory campaign/coupon state) | ||
| 55 | + └── UserDefaultsStore (non-sensitive preferences) | ||
| 56 | +``` | ||
| 57 | + | ||
| 58 | +### Threading Model | ||
| 59 | +- **Main thread:** UI operations, completion callbacks (all callbacks dispatched via `MainActor.run`) | ||
| 60 | +- **Background:** Network requests via `async/await`, database operations | ||
| 61 | +- **Actor isolation:** Token refresh, encryption, keychain access — all actors prevent data races | ||
| 62 | +- **Configuration queue:** `DispatchQueue(label: "com.warply.sdk.configuration")` for thread-safe config updates | ||
| 63 | + | ||
| 64 | +## 3. Directory Structure | ||
| 65 | + | ||
| 66 | +``` | ||
| 67 | +SwiftWarplyFramework/ | ||
| 68 | +├── Package.swift # SPM package definition | ||
| 69 | +├── SwiftWarplyFramework.podspec # CocoaPods spec (v2.3.0) | ||
| 70 | +├── skill.md # This file | ||
| 71 | +├── SwiftWarplyFramework/ | ||
| 72 | +│ ├── SwiftWarplyFramework/ | ||
| 73 | +│ │ ├── Core/ | ||
| 74 | +│ │ │ └── WarplySDK.swift # Main SDK singleton — ALL public APIs | ||
| 75 | +│ │ ├── Network/ | ||
| 76 | +│ │ │ ├── Endpoints.swift # Endpoint enum (all API routes) | ||
| 77 | +│ │ │ ├── NetworkService.swift # HTTP client, request building, auth headers | ||
| 78 | +│ │ │ └── TokenRefreshManager.swift # Actor-based token refresh with retry/circuit breaker | ||
| 79 | +│ │ ├── Database/ | ||
| 80 | +│ │ │ └── DatabaseManager.swift # SQLite.swift wrapper, token/event/POI storage | ||
| 81 | +│ │ ├── Security/ | ||
| 82 | +│ │ │ ├── FieldEncryption.swift # AES-256-GCM field-level encryption (actor) | ||
| 83 | +│ │ │ └── KeychainManager.swift # iOS Keychain key management (actor) | ||
| 84 | +│ │ ├── Configuration/ | ||
| 85 | +│ │ │ ├── WarplyConfiguration.swift # Main config container + presets | ||
| 86 | +│ │ │ ├── DatabaseConfiguration.swift | ||
| 87 | +│ │ │ ├── TokenConfiguration.swift | ||
| 88 | +│ │ │ ├── NetworkConfiguration.swift | ||
| 89 | +│ │ │ └── LoggingConfiguration.swift | ||
| 90 | +│ │ ├── Events/ | ||
| 91 | +│ │ │ └── EventDispatcher.swift # Type-safe event system (replaces SwiftEventBus internally) | ||
| 92 | +│ │ ├── models/ # All data models | ||
| 93 | +│ │ │ ├── Campaign.swift # CampaignItemModel, LoyaltyContextualOfferModel | ||
| 94 | +│ │ │ ├── Coupon.swift # CouponItemModel, CouponSetItemModel, RedeemedSMHistoryModel | ||
| 95 | +│ │ │ ├── TokenModel.swift # JWT token model with expiration parsing | ||
| 96 | +│ │ │ ├── Merchant.swift # MerchantModel | ||
| 97 | +│ │ │ ├── ProfileModel.swift # User profile | ||
| 98 | +│ │ │ ├── CardModel.swift # Credit card model | ||
| 99 | +│ │ │ ├── TransactionModel.swift # Transaction history | ||
| 100 | +│ │ │ ├── PointsHistoryModel.swift # Points history | ||
| 101 | +│ │ │ ├── ArticleModel.swift # Content articles | ||
| 102 | +│ │ │ ├── Market.swift # MarketPassDetailsModel | ||
| 103 | +│ │ │ ├── Response.swift # VerifyTicketResponseModel, GenericResponseModel | ||
| 104 | +│ │ │ ├── Models.swift # LoyaltySDKDynatraceEventModel, LoyaltyGiftsForYouPackage | ||
| 105 | +│ │ │ ├── Events.swift # Event-related models | ||
| 106 | +│ │ │ ├── Gifts.swift # Gift models | ||
| 107 | +│ │ │ ├── OfferModel.swift # Offer models | ||
| 108 | +│ │ │ ├── SectionModel.swift # UI section models | ||
| 109 | +│ │ │ ├── CouponFilterModel.swift # Coupon filter UI models | ||
| 110 | +│ │ │ ├── MerchantCategoryModel.swift # Merchant categories | ||
| 111 | +│ │ │ └── QuestionnaireAnswerModel.swift | ||
| 112 | +│ │ ├── screens/ # Pre-built view controllers | ||
| 113 | +│ │ │ ├── MyRewardsViewController/ # Main rewards screen | ||
| 114 | +│ │ │ ├── ProfileViewController/ # User profile screen | ||
| 115 | +│ │ │ ├── CouponViewController/ # Single coupon detail | ||
| 116 | +│ │ │ ├── CouponsetViewController/ # Coupon set detail | ||
| 117 | +│ │ │ └── CampaignViewController/ # WebView for campaign URLs | ||
| 118 | +│ │ ├── cells/ # Reusable table/collection view cells | ||
| 119 | +│ │ │ ├── MyRewardsBannerOfferCollectionViewCell/ | ||
| 120 | +│ │ │ ├── MyRewardsBannerOffersScrollTableViewCell/ | ||
| 121 | +│ │ │ ├── MyRewardsOfferCollectionViewCell/ | ||
| 122 | +│ │ │ ├── MyRewardsOffersScrollTableViewCell/ | ||
| 123 | +│ │ │ ├── MyRewardsProfileInfoTableViewCell/ | ||
| 124 | +│ │ │ ├── ProfileCouponFiltersTableViewCell/ | ||
| 125 | +│ │ │ ├── ProfileCouponTableViewCell/ | ||
| 126 | +│ │ │ ├── ProfileFilterCollectionViewCell/ | ||
| 127 | +│ │ │ ├── ProfileHeaderTableViewCell/ | ||
| 128 | +│ │ │ └── ProfileQuestionnaireTableViewCell/ | ||
| 129 | +│ │ ├── Helpers/ | ||
| 130 | +│ │ │ ├── WarplyReactMethods.h # React Native bridge (Obj-C header) | ||
| 131 | +│ │ │ └── WarplyReactMethods.m # React Native bridge (Obj-C impl) | ||
| 132 | +│ │ ├── fonts/ # PingLCG font family (.otf) | ||
| 133 | +│ │ ├── Media.xcassets/ # Image assets | ||
| 134 | +│ │ ├── Main.storyboard # Storyboard for campaign WebView | ||
| 135 | +│ │ ├── CopyableLabel.swift # UILabel subclass with copy support | ||
| 136 | +│ │ ├── UIColorExtensions.swift # Color utilities | ||
| 137 | +│ │ ├── ViewControllerExtensions.swift # VC extensions | ||
| 138 | +│ │ ├── XIBLoader.swift # XIB/Bundle loading helpers | ||
| 139 | +│ │ ├── MyEmptyClass.swift # Bundle reference helper | ||
| 140 | +│ │ ├── SwiftWarplyFramework.h # Framework umbrella header | ||
| 141 | +│ │ └── Info.plist | ||
| 142 | +│ └── SwiftWarplyFramework.xcodeproj/ | ||
| 143 | +``` | ||
| 144 | + | ||
| 145 | +## 4. SDK Lifecycle | ||
| 146 | + | ||
| 147 | +### Initialization Flow (Required Order) | ||
| 148 | + | ||
| 149 | +```swift | ||
| 150 | +// Step 1: Configure (sets environment URLs, appUuid, merchantId) | ||
| 151 | +WarplySDK.shared.configure( | ||
| 152 | + appUuid: "your-32-char-hex-uuid", | ||
| 153 | + merchantId: "", | ||
| 154 | + environment: .production, // or .development | ||
| 155 | + language: "el" // "el" or "en" | ||
| 156 | +) | ||
| 157 | + | ||
| 158 | +// Step 2: Initialize (validates config, initializes DB, registers device) | ||
| 159 | +WarplySDK.shared.initialize { success in | ||
| 160 | + // SDK ready to use | ||
| 161 | +} | ||
| 162 | +// OR async: | ||
| 163 | +try await WarplySDK.shared.initialize() | ||
| 164 | + | ||
| 165 | +// Step 3: Authenticate user (one of these methods) | ||
| 166 | +// DEI Login: | ||
| 167 | +WarplySDK.shared.deiLogin(email: "user@example.com") { response in ... } | ||
| 168 | +// OR Cosmote flow: | ||
| 169 | +WarplySDK.shared.verifyTicket(guid: "...", ticket: "...") { response in ... } | ||
| 170 | +// OR Cosmote user: | ||
| 171 | +WarplySDK.shared.getCosmoteUser(guid: "...") { response in ... } | ||
| 172 | +``` | ||
| 173 | + | ||
| 174 | +### What Happens During `initialize()` | ||
| 175 | +1. Validates `appUuid` is not empty | ||
| 176 | +2. Sets `Configuration.baseURL` and `Configuration.host` from stored environment | ||
| 177 | +3. Stores `appUuid` in UserDefaults for NetworkService access | ||
| 178 | +4. Initializes SQLite database (`DatabaseManager.shared.initializeDatabase()`) | ||
| 179 | +5. Performs automatic device registration with comprehensive device info | ||
| 180 | +6. Posts Dynatrace analytics event on success/failure | ||
| 181 | + | ||
| 182 | +### Authentication Token Flow | ||
| 183 | +1. Login methods (`deiLogin`, `verifyTicket`, `getCosmoteUser`) receive JWT tokens from server | ||
| 184 | +2. Tokens are parsed into `TokenModel` (automatic JWT `exp` claim extraction) | ||
| 185 | +3. `TokenModel` stored in SQLite `requestVariables` table via `DatabaseManager` | ||
| 186 | +4. `NetworkService` reads tokens from database for authenticated requests | ||
| 187 | +5. Proactive refresh: tokens refreshed 5 minutes before expiration | ||
| 188 | +6. On 401 response: automatic token refresh + request retry | ||
| 189 | +7. Logout: tokens cleared from database | ||
| 190 | + | ||
| 191 | +### Environment Configuration | ||
| 192 | +| Environment | Base URL | Host | | ||
| 193 | +|-------------|----------|------| | ||
| 194 | +| `.development` | `https://engage-uat.dei.gr` | `engage-uat.dei.gr` | | ||
| 195 | +| `.production` | `https://engage-prod.dei.gr` | `engage-prod.dei.gr` | | ||
| 196 | + | ||
| 197 | +## 5. Core Components | ||
| 198 | + | ||
| 199 | +### WarplySDK (`Core/WarplySDK.swift`) | ||
| 200 | + | ||
| 201 | +The **sole public API surface** of the framework. All client interactions go through `WarplySDK.shared`. | ||
| 202 | + | ||
| 203 | +**Key Responsibilities:** | ||
| 204 | +- SDK configuration and initialization | ||
| 205 | +- All network API calls (campaigns, coupons, merchants, profile, cards, transactions) | ||
| 206 | +- In-memory state management (campaigns, coupons, merchants, coupon sets) | ||
| 207 | +- Event posting (dual: SwiftEventBus + EventDispatcher) | ||
| 208 | +- Campaign URL construction for WebView | ||
| 209 | +- UI presentation helpers (map, supermarket flow, dialogs) | ||
| 210 | + | ||
| 211 | +**Internal Architecture:** | ||
| 212 | +- Uses `NetworkService` for all HTTP calls | ||
| 213 | +- Uses `DatabaseManager` for token storage | ||
| 214 | +- Uses `SDKState` (private class) for in-memory data cache | ||
| 215 | +- Uses `UserDefaultsStore` (private class) for preferences | ||
| 216 | +- Uses `EventDispatcher` for modern event dispatching | ||
| 217 | + | ||
| 218 | +**State Management Pattern:** | ||
| 219 | +```swift | ||
| 220 | +// All state is stored in SDKState.shared (private) | ||
| 221 | +// Accessed via getter/setter methods on WarplySDK: | ||
| 222 | +WarplySDK.shared.setCampaignList(campaigns) // stores sorted by _sorting | ||
| 223 | +WarplySDK.shared.getCampaignList() // returns filtered (no ccms, no telco, no questionnaire) | ||
| 224 | +WarplySDK.shared.getAllCampaignList() // returns unfiltered | ||
| 225 | +WarplySDK.shared.setCouponList(coupons) // filters active (status==1), sorts by expiration | ||
| 226 | +WarplySDK.shared.getCouponList() // returns active coupons | ||
| 227 | +WarplySDK.shared.setOldCouponList(coupons) // filters redeemed (status==0), sorts by redeemed_date desc | ||
| 228 | +``` | ||
| 229 | + | ||
| 230 | +**Campaign Processing Logic (Authenticated Users):** | ||
| 231 | +1. Fetch basic campaigns from `/api/mobile/v2/{appUUID}/context/` | ||
| 232 | +2. Fetch personalized campaigns from `/oauth/{appUUID}/context` | ||
| 233 | +3. Merge both arrays | ||
| 234 | +4. Fetch coupon availability | ||
| 235 | +5. Set `_coupon_availability` on each campaign matching its couponset | ||
| 236 | +6. Filter: remove campaigns with `_coupon_availability == 0` | ||
| 237 | +7. Separate carousel items (`_carousel == "true"`) | ||
| 238 | +8. Remove ccms offers, telco, and questionnaire campaigns | ||
| 239 | +9. Sort by `_sorting` field | ||
| 240 | + | ||
| 241 | +**Campaign Processing Logic (Unauthenticated Users):** | ||
| 242 | +- Only fetch basic campaigns — return ALL without coupon filtering | ||
| 243 | + | ||
| 244 | +## 6. Network Layer | ||
| 245 | + | ||
| 246 | +### NetworkService (`Network/NetworkService.swift`) | ||
| 247 | + | ||
| 248 | +URLSession-based HTTP client with automatic auth header injection, token refresh, and request/response logging. | ||
| 249 | + | ||
| 250 | +**Key Features:** | ||
| 251 | +- Dynamic `baseURL` from `Configuration.baseURL` | ||
| 252 | +- Network connectivity monitoring via `NWPathMonitor` | ||
| 253 | +- Automatic placeholder replacement in URLs and request bodies | ||
| 254 | +- Context response transformation (flattens `{"status":"1","context":{...}}` pattern) | ||
| 255 | +- Proactive token refresh before requests (5 min threshold) | ||
| 256 | +- Automatic 401 retry with token refresh | ||
| 257 | +- Comprehensive request/response logging with sensitive data masking | ||
| 258 | + | ||
| 259 | +### Endpoint System (`Network/Endpoints.swift`) | ||
| 260 | + | ||
| 261 | +All API routes are defined as cases of the `Endpoint` enum. | ||
| 262 | + | ||
| 263 | +**Endpoint Categories:** | ||
| 264 | + | ||
| 265 | +| Category | Base Path | Auth Type | Examples | | ||
| 266 | +|----------|-----------|-----------|----------| | ||
| 267 | +| `standardContext` | `/api/mobile/v2/{appUUID}/context/` | Standard (loyalty headers) | getCampaigns, getAvailableCoupons, getCouponSets, getMerchantCategories, getArticles | | ||
| 268 | +| `authenticatedContext` | `/oauth/{appUUID}/context` | Bearer Token | getCoupons, getProfile, getMarketPassDetails, addCard, getCards, deleteCard, getTransactionHistory, getPointsHistory, validateCoupon, redeemCoupon, getCampaignsPersonalized | | ||
| 269 | +| `userManagement` | `/user/{appUUID}/*` | Standard | register, changePassword, resetPassword, requestOtp | | ||
| 270 | +| `authentication` | `/oauth/{appUUID}/*` | Standard | refreshToken, logout | | ||
| 271 | +| `partnerCosmote` | `/partners/*` | Basic/Standard | verifyTicket, getCosmoteUser, deiLogin | | ||
| 272 | +| `session` | `/api/session/{sessionUuid}` | Standard (GET) | getSingleCampaign | | ||
| 273 | +| `analytics` | `/api/async/analytics/{appUUID}/` | Standard | sendEvent | | ||
| 274 | +| `deviceInfo` | `/api/async/info/{appUUID}/` | Standard | sendDeviceInfo | | ||
| 275 | + | ||
| 276 | +**Authentication Types:** | ||
| 277 | + | ||
| 278 | +| Type | Headers Added | | ||
| 279 | +|------|--------------| | ||
| 280 | +| `.standard` | `loyalty-web-id`, `loyalty-date`, `loyalty-signature` (SHA256 of apiKey+timestamp), platform headers, device headers | | ||
| 281 | +| `.bearerToken` | All standard headers + `Authorization: Bearer {access_token}` (from database) | | ||
| 282 | +| `.basicAuth` | All standard headers + `Authorization: Basic {encoded_credentials}` (for Cosmote partner endpoints) | | ||
| 283 | + | ||
| 284 | +**Headers Always Sent:** | ||
| 285 | +- `loyalty-web-id` — merchant ID from Configuration | ||
| 286 | +- `loyalty-date` — Unix timestamp | ||
| 287 | +- `loyalty-signature` — SHA256 hash of `"{apiKey}{timestamp}"` | ||
| 288 | +- `Accept-Encoding: gzip` | ||
| 289 | +- `Accept: application/json` | ||
| 290 | +- `User-Agent: gzip` | ||
| 291 | +- `channel: mobile` | ||
| 292 | +- `loyalty-bundle-id: ios:{bundleId}` | ||
| 293 | +- `unique-device-id: {IDFV}` | ||
| 294 | +- `vendor: apple`, `platform: ios`, `os_version: {version}` | ||
| 295 | + | ||
| 296 | +**Placeholder Replacement System:** | ||
| 297 | +- `{appUUID}` → App UUID from UserDefaults | ||
| 298 | +- `{access_token}` → Access token from database | ||
| 299 | +- `{refresh_token}` → Refresh token from database | ||
| 300 | +- `{web_id}` → Merchant ID from Configuration | ||
| 301 | +- `{api_key}` → Empty string (legacy, deprecated) | ||
| 302 | +- `{sessionUuid}` → Extracted from endpoint parameters | ||
| 303 | + | ||
| 304 | +**Context Response Transformation:** | ||
| 305 | +``` | ||
| 306 | +Server returns: {"status": "1", "context": {"MAPP_CAMPAIGNING": {...}}} | ||
| 307 | +Transformed to: {"status": 1, "MAPP_CAMPAIGNING": {...}} | ||
| 308 | +``` | ||
| 309 | +This flattening happens in `transformContextResponse()` for backward compatibility. | ||
| 310 | + | ||
| 311 | +**Request Parameter Structures:** | ||
| 312 | +Most POST endpoints wrap parameters in a domain-specific key: | ||
| 313 | +```swift | ||
| 314 | +// Campaigns: {"campaigns": {"action": "retrieve", "language": "el", "filters": {...}}} | ||
| 315 | +// Coupons: {"coupon": {"action": "user_coupons", "details": ["merchant","redemption"], ...}} | ||
| 316 | +// Profile: {"consumer_data": {"action": "handle_user_details", "process": "get"}} | ||
| 317 | +// Cards: {"cards": {"action": "add_card", "card_number": "...", ...}} | ||
| 318 | +// Merchants: {"shops": {"language": "el", "action": "retrieve_multilingual"}} | ||
| 319 | +// Articles: {"content": {"language": "el", "action": "retrieve_multilingual"}} | ||
| 320 | +``` | ||
| 321 | + | ||
| 322 | +### TokenRefreshManager (`Network/TokenRefreshManager.swift`) | ||
| 323 | + | ||
| 324 | +Actor-based coordinator for JWT token refresh with retry and circuit breaker. | ||
| 325 | + | ||
| 326 | +**Retry Logic:** | ||
| 327 | +- Configurable `maxRetryAttempts` (default: 3) and `retryDelays` (default: [0.0, 1.0, 5.0]) | ||
| 328 | +- 401/4xx errors: no retry (permanent failure) | ||
| 329 | +- 5xx/network errors: retry with backoff | ||
| 330 | +- After all retries fail: clears tokens from database | ||
| 331 | + | ||
| 332 | +**Circuit Breaker:** | ||
| 333 | +- States: `closed` (normal) → `open` (blocking) → `halfOpen` (testing recovery) | ||
| 334 | +- Opens after configurable `failureThreshold` (default: 5) consecutive failures | ||
| 335 | +- Recovery timeout: configurable (default: 300s / 5 minutes) | ||
| 336 | + | ||
| 337 | +**Request Deduplication:** | ||
| 338 | +- If a refresh is already in progress, subsequent callers await the same `Task` | ||
| 339 | +- Prevents multiple simultaneous refresh API calls | ||
| 340 | + | ||
| 341 | +## 7. Database Layer | ||
| 342 | + | ||
| 343 | +### DatabaseManager (`Database/DatabaseManager.swift`) | ||
| 344 | + | ||
| 345 | +SQLite.swift-based database manager using **raw SQL** (not Expression builders) for all operations. | ||
| 346 | + | ||
| 347 | +**Database File:** `{Documents}/WarplyCache_{bundleId}.db` | ||
| 348 | + | ||
| 349 | +**Tables (Schema Version 1):** | ||
| 350 | + | ||
| 351 | +| Table | Columns | Purpose | | ||
| 352 | +|-------|---------|---------| | ||
| 353 | +| `requestVariables` | `id` INTEGER PK, `client_id` TEXT, `client_secret` TEXT, `access_token` TEXT, `refresh_token` TEXT | JWT token storage (single row, UPSERT) | | ||
| 354 | +| `events` | `_id` INTEGER PK, `type` TEXT, `time` TEXT, `data` BLOB, `priority` INTEGER | Offline analytics event queue | | ||
| 355 | +| `pois` | `id` INTEGER PK, `lat` REAL, `lon` REAL, `radius` REAL | Geofencing points of interest | | ||
| 356 | +| `schema_version` | `id` INTEGER PK, `version` INTEGER UNIQUE, `created_at` TEXT | Migration tracking | | ||
| 357 | + | ||
| 358 | +**Schema Migration System:** | ||
| 359 | +- Version tracked in `schema_version` table | ||
| 360 | +- `currentDatabaseVersion = 1`, `supportedVersions = [1]` | ||
| 361 | +- Migration runs in a transaction for atomicity | ||
| 362 | +- Fresh installs: `createAllTables()` + set version | ||
| 363 | +- Upgrades: `migrateDatabase(from:to:)` with per-version migration functions | ||
| 364 | + | ||
| 365 | +**Token Management Methods:** | ||
| 366 | +```swift | ||
| 367 | +// Basic CRUD | ||
| 368 | +storeTokens(accessToken:refreshToken:clientId:clientSecret:) // UPSERT | ||
| 369 | +getAccessToken() async throws -> String? | ||
| 370 | +getRefreshToken() async throws -> String? | ||
| 371 | +getClientCredentials() async throws -> (clientId: String?, clientSecret: String?) | ||
| 372 | +clearTokens() async throws | ||
| 373 | + | ||
| 374 | +// TokenModel integration (preferred) | ||
| 375 | +storeTokenModel(_ tokenModel: TokenModel) async throws | ||
| 376 | +getTokenModel() async throws -> TokenModel? | ||
| 377 | +getValidTokenModel() async throws -> TokenModel? // nil if expired | ||
| 378 | +getTokenModelSync() throws -> TokenModel? // synchronous variant | ||
| 379 | +updateTokensAtomically(from:to:) async throws // race-condition safe | ||
| 380 | + | ||
| 381 | +// Encryption-aware (smart routing) | ||
| 382 | +storeTokenModelSmart(_ tokenModel: TokenModel) // auto-chooses encrypted/plain | ||
| 383 | +getTokenModelSmart() async throws -> TokenModel? // auto-chooses decrypted/plain | ||
| 384 | + | ||
| 385 | +// Performance | ||
| 386 | +getCachedTokenModel() async throws -> TokenModel? // 60-second memory cache | ||
| 387 | +``` | ||
| 388 | + | ||
| 389 | +**Concurrency:** Uses `DispatchQueue(label: "com.warply.database", qos: .utility)` for thread safety. | ||
| 390 | + | ||
| 391 | +**Maintenance Methods:** `checkDatabaseIntegrity()`, `vacuumDatabase()`, `recreateDatabase()`, `getDatabaseStats()` | ||
| 392 | + | ||
| 393 | +## 8. Security Layer | ||
| 394 | + | ||
| 395 | +### FieldEncryption (`Security/FieldEncryption.swift`) | ||
| 396 | + | ||
| 397 | +Actor-based AES-256-GCM field-level encryption for sensitive token data. | ||
| 398 | + | ||
| 399 | +**Algorithm:** AES-256-GCM (via Apple CryptoKit) | ||
| 400 | +- Provides both encryption AND authentication (AEAD) | ||
| 401 | +- Output format: nonce + ciphertext + authentication tag (combined) | ||
| 402 | +- Keys: 256-bit (32 bytes) from iOS Keychain | ||
| 403 | + | ||
| 404 | +**Key Methods:** | ||
| 405 | +```swift | ||
| 406 | +// Low-level (provide your own key) | ||
| 407 | +encryptToken(_ token: String, using key: Data) throws -> Data | ||
| 408 | +decryptToken(_ encryptedData: Data, using key: Data) throws -> String | ||
| 409 | + | ||
| 410 | +// High-level (auto key from KeychainManager, with caching) | ||
| 411 | +encryptSensitiveData(_ data: String) async throws -> Data | ||
| 412 | +decryptSensitiveData(_ encryptedData: Data) async throws -> String | ||
| 413 | + | ||
| 414 | +// Batch operations (single key retrieval for multiple items) | ||
| 415 | +encryptSensitiveDataBatch(_ dataArray: [String]) async throws -> [Data] | ||
| 416 | +decryptSensitiveDataBatch(_ encryptedDataArray: [Data]) async throws -> [String] | ||
| 417 | +``` | ||
| 418 | + | ||
| 419 | +**Key Caching:** Encryption key cached in memory for 300 seconds (5 min) to reduce Keychain lookups. | ||
| 420 | + | ||
| 421 | +**Encrypted Token Storage:** When encryption is enabled, tokens are stored as Base64-encoded encrypted data in the database. The system detects encrypted vs. plain text by checking if data is valid Base64 but NOT a JWT (JWTs start with "eyJ"). | ||
| 422 | + | ||
| 423 | +### KeychainManager (`Security/KeychainManager.swift`) | ||
| 424 | + | ||
| 425 | +Actor-based iOS Keychain wrapper for hardware-backed encryption key management. | ||
| 426 | + | ||
| 427 | +**Key Features:** | ||
| 428 | +- **Bundle ID isolation:** Each client app gets its own Keychain namespace via `com.warply.sdk.{bundleId}` | ||
| 429 | +- **Auto key generation:** `getOrCreateDatabaseKey()` creates 256-bit key on first call via `SecRandomCopyBytes` | ||
| 430 | +- **Security level:** `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` — key only available when device is unlocked, never backed up | ||
| 431 | + | ||
| 432 | +**Keychain Item Structure:** | ||
| 433 | +- Service: `com.warply.sdk.{bundleId}` | ||
| 434 | +- Account: `database_encryption_key` | ||
| 435 | +- Class: `kSecClassGenericPassword` | ||
| 436 | +- Value: 32 bytes (256-bit AES key) | ||
| 437 | + | ||
| 438 | +## 9. Event System | ||
| 439 | + | ||
| 440 | +### EventDispatcher (`Events/EventDispatcher.swift`) | ||
| 441 | + | ||
| 442 | +Modern Swift event system designed to eventually replace SwiftEventBus. | ||
| 443 | + | ||
| 444 | +**Architecture:** | ||
| 445 | +- Thread-safe using concurrent `DispatchQueue` with barrier writes | ||
| 446 | +- Handlers always dispatched on `DispatchQueue.main` | ||
| 447 | +- Subscription-based with auto-cleanup via `deinit` | ||
| 448 | + | ||
| 449 | +**Event Types (Protocol-based):** | ||
| 450 | +```swift | ||
| 451 | +public protocol WarplyEvent { | ||
| 452 | + var name: String { get } | ||
| 453 | + var timestamp: Date { get } | ||
| 454 | + var data: Any? { get } | ||
| 455 | +} | ||
| 456 | +``` | ||
| 457 | + | ||
| 458 | +**Built-in Event Types:** | ||
| 459 | + | ||
| 460 | +| Event Struct | Event Name | Triggered When | | ||
| 461 | +|-------------|------------|----------------| | ||
| 462 | +| `CampaignsRetrievedEvent` | `"campaigns_retrieved"` | Campaigns fetched from server | | ||
| 463 | +| `CouponsRetrievedEvent` | `"coupons_fetched"` | Coupons fetched from server | | ||
| 464 | +| `MarketPassDetailsEvent` | `"market_pass_details_fetched"` | Market pass details received | | ||
| 465 | +| `CCMSRetrievedEvent` | `"ccms_retrieved"` | CCMS campaigns set | | ||
| 466 | +| `SeasonalsRetrievedEvent` | `"seasonals_retrieved"` | Seasonal list set | | ||
| 467 | +| `DynatraceEvent` | `"dynatrace"` | Analytics event | | ||
| 468 | +| `GenericWarplyEvent` | (any string) | Backward compatibility | | ||
| 469 | + | ||
| 470 | +**Dual Posting Pattern (CRITICAL):** | ||
| 471 | +All events in WarplySDK are posted to BOTH systems for backward compatibility: | ||
| 472 | +```swift | ||
| 473 | +// Internal helper method in WarplySDK: | ||
| 474 | +private func postFrameworkEvent(_ eventName: String, sender: Any? = nil) { | ||
| 475 | + SwiftEventBus.post(eventName, sender: sender) // Client compatibility | ||
| 476 | + eventDispatcher.post(eventName, sender: sender) // Modern internal | ||
| 477 | +} | ||
| 478 | +``` | ||
| 479 | + | ||
| 480 | +**Client Subscription API:** | ||
| 481 | +```swift | ||
| 482 | +// String-based (backward compatible) | ||
| 483 | +WarplySDK.shared.subscribe(to: "campaigns_retrieved") { data in ... } | ||
| 484 | + | ||
| 485 | +// Type-safe (modern) | ||
| 486 | +WarplySDK.shared.subscribe(CampaignsRetrievedEvent.self) { event in ... } | ||
| 487 | +``` | ||
| 488 | + | ||
| 489 | +**Dynatrace Analytics Pattern:** | ||
| 490 | +Every SDK API call posts a Dynatrace event on success AND failure: | ||
| 491 | +```swift | ||
| 492 | +let dynatraceEvent = LoyaltySDKDynatraceEventModel() | ||
| 493 | +dynatraceEvent._eventName = "custom_success_campaigns_loyalty" // or custom_error_* | ||
| 494 | +dynatraceEvent._parameters = nil | ||
| 495 | +self.postFrameworkEvent("dynatrace", sender: dynatraceEvent) | ||
| 496 | +``` | ||
| 497 | +Event naming convention: `custom_{success|error}_{operation}_loyalty` | ||
| 498 | + | ||
| 499 | +## 10. Configuration System | ||
| 500 | + | ||
| 501 | +### WarplyConfiguration (`Configuration/WarplyConfiguration.swift`) | ||
| 502 | + | ||
| 503 | +Codable struct with nested sub-configurations for each subsystem. | ||
| 504 | + | ||
| 505 | +**Sub-configurations:** | ||
| 506 | + | ||
| 507 | +| Config Struct | Controls | Key Fields | | ||
| 508 | +|--------------|----------|------------| | ||
| 509 | +| `WarplyDatabaseConfig` | Encryption, WAL mode, cache | `encryptionEnabled`, `dataProtectionClass`, `enableWALMode`, `cacheSize`, `useKeychainForKeys` | | ||
| 510 | +| `WarplyTokenConfig` | Refresh retry, circuit breaker | `maxRetryAttempts`, `retryDelays`, `circuitBreakerThreshold`, `circuitBreakerResetTime`, `refreshThresholdMinutes` | | ||
| 511 | +| `WarplyNetworkConfig` | Timeouts, retries, caching | `requestTimeout`, `resourceTimeout`, `maxRetryAttempts`, `retryDelay`, `maxConcurrentRequests`, `enableResponseCaching`, `enableExponentialBackoff`, `allowsCellularAccess` | | ||
| 512 | +| `WarplyLoggingConfig` | Log levels, masking | `logLevel` (.none/.error/.warning/.info/.debug/.verbose), `enableDatabaseLogging`, `enableNetworkLogging`, `enableTokenLogging`, `maskSensitiveData`, `enableFileLogging` | | ||
| 513 | + | ||
| 514 | +**Preset Configurations:** | ||
| 515 | + | ||
| 516 | +| Preset | Encryption | Log Level | Timeout | Token Retries | Use Case | | ||
| 517 | +|--------|-----------|-----------|---------|---------------|----------| | ||
| 518 | +| `.development` | Off | `.verbose` | 15s | 2 | Local dev, full logging | | ||
| 519 | +| `.production` | On | `.warning` | 30s | 3 | Release builds | | ||
| 520 | +| `.testing` | Off | `.error` | 5s | 1 | Unit/integration tests | | ||
| 521 | +| `.highSecurity` | On | `.error` | 20s | 2 | Sensitive environments, WiFi only | | ||
| 522 | + | ||
| 523 | +**Usage:** | ||
| 524 | +```swift | ||
| 525 | +// Apply preset | ||
| 526 | +try await WarplySDK.shared.configure(WarplyConfiguration.production) | ||
| 527 | + | ||
| 528 | +// Custom config | ||
| 529 | +var config = WarplyConfiguration.development | ||
| 530 | +config.databaseConfig.encryptionEnabled = true | ||
| 531 | +config.tokenConfig.maxRetryAttempts = 5 | ||
| 532 | +try await WarplySDK.shared.configure(config) | ||
| 533 | + | ||
| 534 | +// Per-component configuration | ||
| 535 | +try await WarplySDK.shared.configureDatabaseSecurity(dbConfig) | ||
| 536 | +try await WarplySDK.shared.configureTokenManagement(tokenConfig) | ||
| 537 | +try await WarplySDK.shared.configureLogging(loggingConfig) | ||
| 538 | +try await WarplySDK.shared.configureNetwork(networkConfig) | ||
| 539 | +``` | ||
| 540 | + | ||
| 541 | +**Validation:** Each config struct has a `validate()` method that throws `ConfigurationError` with specific error types and recovery suggestions. | ||
| 542 | + | ||
| 543 | +**Serialization:** `WarplyConfiguration` is `Codable` — can be saved/loaded as JSON via `toJSONData()`, `fromJSONData()`, `saveToFile(at:)`, `loadFromFile(at:)`. | ||
| 544 | + | ||
| 545 | +## 11. Data Models | ||
| 546 | + | ||
| 547 | +All models use **dictionary-based initialization** (`init(dictionary: [String: Any])`) to parse JSON responses. | ||
| 548 | + | ||
| 549 | +### Key Model Classes | ||
| 550 | + | ||
| 551 | +| Model | File | Key Fields | Notes | | ||
| 552 | +|-------|------|------------|-------| | ||
| 553 | +| `CampaignItemModel` | Campaign.swift | `session_uuid`, `index_url`, `title`, `subtitle`, `offer_category`, `logo_url`, `couponset`, `_sorting`, `_type`, `_carousel`, `_coupon_availability`, `_banner_img`, `_filter`, `_start_date`, `_end_date` | `class` (not struct), mutable properties via `_fieldName` pattern | | ||
| 554 | +| `LoyaltyContextualOfferModel` | Campaign.swift | `_loyaltyCampaignId`, `_eligibleAssets`, `_offerName`, `_sessionId` | CCMS contextual offers | | ||
| 555 | +| `CouponItemModel` | Coupon.swift | `couponset_uuid`, `name`, `coupon`, `barcode`, `status` (1=active, 0=redeemed), `expiration`, `redeemed_date`, `couponset_data`, `merchant_details` | Status: 1=active, 0=redeemed | | ||
| 556 | +| `CouponSetItemModel` | Coupon.swift | `_uuid`, `_name`, `_description`, `_img_preview`, `_expiration`, `_discount`, `_discount_type`, `_final_price`, `_couponset_type`, `_merchant_uuid`, `_terms`, `_shop_availability` | Expiration stored as raw date string "yyyy-MM-dd HH:mm" | | ||
| 557 | +| `TokenModel` | TokenModel.swift | `accessToken`, `refreshToken`, `clientId`, `clientSecret`, `expirationDate`, `isExpired`, `shouldRefresh`, `isValid`, `canRefresh` | `struct`, JWT auto-parsing, 5-min proactive refresh window | | ||
| 558 | +| `MerchantModel` | Merchant.swift | Standard merchant fields | Location-aware merchant data | | ||
| 559 | +| `ProfileModel` | ProfileModel.swift | User profile fields | Consumer data | | ||
| 560 | +| `CardModel` | CardModel.swift | Card number, issuer, holder, expiration | Credit card data | | ||
| 561 | +| `TransactionModel` | TransactionModel.swift | `transactionDate`, transaction details | Sorted by date descending | | ||
| 562 | +| `PointsHistoryModel` | PointsHistoryModel.swift | `entryDate`, points entries | Sorted by date descending | | ||
| 563 | +| `MarketPassDetailsModel` | Market.swift | Supermarket pass details | Market/supermarket feature | | ||
| 564 | +| `RedeemedSMHistoryModel` | Coupon.swift | `_totalRedeemedValue`, `_redeemedCouponList` | Supermarket coupon redemption history | | ||
| 565 | +| `ArticleModel` | ArticleModel.swift | Content/article fields | Carousel content | | ||
| 566 | +| `MerchantCategoryModel` | MerchantCategoryModel.swift | Category fields | Merchant categorization | | ||
| 567 | +| `VerifyTicketResponseModel` | Response.swift | `getStatus` (1=success) | Generic API response wrapper | | ||
| 568 | +| `GenericResponseModel` | Response.swift | Generic response fields | Flexible response wrapper | | ||
| 569 | +| `LoyaltySDKDynatraceEventModel` | Models.swift | `_eventName`, `_parameters` | Dynatrace analytics event payload | | ||
| 570 | +| `LoyaltyGiftsForYouPackage` | Models.swift/Gifts.swift | Seasonal gift data | Seasonal promotions | | ||
| 571 | + | ||
| 572 | +### Model Property Accessor Pattern | ||
| 573 | + | ||
| 574 | +Models use a **underscore-prefixed computed property** pattern for public access: | ||
| 575 | +```swift | ||
| 576 | +// Private stored property | ||
| 577 | +private var category_title: String? | ||
| 578 | + | ||
| 579 | +// Public accessor (getter + setter) | ||
| 580 | +public var _category_title: String? { | ||
| 581 | + get { return self.category_title } | ||
| 582 | + set(newValue) { self.category_title = newValue } | ||
| 583 | +} | ||
| 584 | +``` | ||
| 585 | + | ||
| 586 | +Some newer models use **read-only** accessors: | ||
| 587 | +```swift | ||
| 588 | +public var _uuid: String { get { return self.uuid ?? "" } } | ||
| 589 | +``` | ||
| 590 | + | ||
| 591 | +### Date Handling in Models | ||
| 592 | +- **CampaignItemModel:** Raw strings from server, formatted via `formattedStartDate(format:)` / `formattedEndDate(format:)` | ||
| 593 | +- **CouponSetItemModel:** Expiration as "yyyy-MM-dd HH:mm", formatted via `formattedExpiration(format:)`, `_expiration_formatted` (dd/MM/yyyy) | ||
| 594 | +- **CouponItemModel:** Dates converted during init to "dd/MM/yyyy" display format, `redeemed_date` as `Date` object | ||
| 595 | +- Input formats tried: `"yyyy-MM-dd HH:mm:ssZZZZZ"`, `"yyyy-MM-dd HH:mm:ss"`, `"yyyy-MM-dd HH:mm:ss.SSSSSS"` | ||
| 596 | + | ||
| 597 | +### URL Cleaning | ||
| 598 | +Campaign model has a static helper that cleans escaped URLs: | ||
| 599 | +```swift | ||
| 600 | +private static func cleanEscapedUrl(_ url: String?) -> String? { | ||
| 601 | + return url?.replacingOccurrences(of: "\\/", with: "/") | ||
| 602 | +} | ||
| 603 | +``` | ||
| 604 | +Applied to: `index_url`, `logo_url`, `banner_img`, `coupon_img`, `campaign_url`, `banner_img_mobile` | ||
| 605 | + | ||
| 606 | +## 12. UI Layer | ||
| 607 | + | ||
| 608 | +### Pre-built Screens | ||
| 609 | + | ||
| 610 | +All screens use **XIB-based** layouts (not SwiftUI) with `Bundle.frameworkBundle` for resource loading. | ||
| 611 | + | ||
| 612 | +| Screen | Storyboard ID | Purpose | | ||
| 613 | +|--------|--------------|---------| | ||
| 614 | +| `MyRewardsViewController` | — (XIB) | Main rewards dashboard with campaigns, offers, banners | | ||
| 615 | +| `ProfileViewController` | — (XIB) | User profile with coupons, filters, questionnaires | | ||
| 616 | +| `CouponViewController` | — (XIB) | Single coupon detail view | | ||
| 617 | +| `CouponsetViewController` | — (XIB) | Coupon set detail view | | ||
| 618 | +| `CampaignViewController` | `"CampaignViewController"` (Storyboard) | WebView for campaign URLs | | ||
| 619 | + | ||
| 620 | +### CampaignViewController (WebView) | ||
| 621 | +Used to display campaign web content. Configured via: | ||
| 622 | +```swift | ||
| 623 | +let vc = storyboard.instantiateViewController(withIdentifier: "CampaignViewController") as! CampaignViewController | ||
| 624 | +vc.campaignUrl = url // Campaign web URL | ||
| 625 | +vc.params = params // JSON string with tokens and config | ||
| 626 | +vc.showHeader = false // Header visibility | ||
| 627 | +vc.isPresented = true/false // Modal vs push navigation | ||
| 628 | +``` | ||
| 629 | + | ||
| 630 | +### Navigation Pattern | ||
| 631 | +Screens support both push (navigation controller) and modal presentation: | ||
| 632 | +```swift | ||
| 633 | +if let navigationController = controller.navigationController { | ||
| 634 | + vc.isPresented = false | ||
| 635 | + navigationController.pushViewController(vc, animated: true) | ||
| 636 | +} else { | ||
| 637 | + vc.isPresented = true | ||
| 638 | + vc.modalPresentationStyle = .fullScreen | ||
| 639 | + controller.present(vc, animated: true, completion: nil) | ||
| 640 | +} | ||
| 641 | +``` | ||
| 642 | + | ||
| 643 | +### Cell Architecture | ||
| 644 | +Table/Collection view cells are XIB-based, each in its own directory: | ||
| 645 | +- `MyRewardsBannerOfferCollectionViewCell` — Banner offer in collection view | ||
| 646 | +- `MyRewardsOfferCollectionViewCell` — Standard offer in collection view | ||
| 647 | +- `MyRewardsBannerOffersScrollTableViewCell` — Horizontal scrolling banner offers | ||
| 648 | +- `MyRewardsOffersScrollTableViewCell` — Horizontal scrolling offers | ||
| 649 | +- `MyRewardsProfileInfoTableViewCell` — Profile info header | ||
| 650 | +- `ProfileCouponTableViewCell` — Coupon row in profile | ||
| 651 | +- `ProfileCouponFiltersTableViewCell` — Filter chips for coupons | ||
| 652 | +- `ProfileFilterCollectionViewCell` — Individual filter chip | ||
| 653 | +- `ProfileHeaderTableViewCell` — Profile section header | ||
| 654 | +- `ProfileQuestionnaireTableViewCell` — Questionnaire entry | ||
| 655 | + | ||
| 656 | +### Fonts | ||
| 657 | +Custom fonts bundled: **PingLCG** family (Bold, Light, Regular) in `.otf` format. | ||
| 658 | + | ||
| 659 | +### Bundle Resource Loading | ||
| 660 | +Framework resources loaded via `Bundle.frameworkBundle` (defined in `XIBLoader.swift`/`MyEmptyClass.swift`), which resolves the correct bundle whether using CocoaPods resource bundles or SPM. | ||
| 661 | + | ||
| 662 | +## 13. API Patterns & Conventions | ||
| 663 | + | ||
| 664 | +### Dual API Surface (Callback + Async/Await) | ||
| 665 | + | ||
| 666 | +Every public API method has two variants: | ||
| 667 | + | ||
| 668 | +```swift | ||
| 669 | +// Callback-based (original pattern) | ||
| 670 | +public func getCoupons(language: String, completion: @escaping ([CouponItemModel]?) -> Void, failureCallback: @escaping (Int) -> Void) | ||
| 671 | + | ||
| 672 | +// Async/await wrapper (bridges to callback version) | ||
| 673 | +public func getCoupons(language: String) async throws -> [CouponItemModel] { | ||
| 674 | + return try await withCheckedThrowingContinuation { continuation in | ||
| 675 | + getCoupons(language: language, completion: { coupons in | ||
| 676 | + if let coupons = coupons { | ||
| 677 | + continuation.resume(returning: coupons) | ||
| 678 | + } else { | ||
| 679 | + continuation.resume(throwing: WarplyError.networkError) | ||
| 680 | + } | ||
| 681 | + }, failureCallback: { errorCode in | ||
| 682 | + continuation.resume(throwing: WarplyError.unknownError(errorCode)) | ||
| 683 | + }) | ||
| 684 | + } | ||
| 685 | +} | ||
| 686 | +``` | ||
| 687 | + | ||
| 688 | +### Standard Method Body Pattern | ||
| 689 | + | ||
| 690 | +Every API method follows this exact pattern: | ||
| 691 | +1. Create `Task { }` block | ||
| 692 | +2. Call `networkService.requestRaw(endpoint)` or specific convenience method | ||
| 693 | +3. Parse response inside `await MainActor.run { }` | ||
| 694 | +4. Check status (`response["status"] as? Int == 1` or `response["MAPP_*-status"] as? Int == 1`) | ||
| 695 | +5. Post Dynatrace success/error event | ||
| 696 | +6. Call completion handler on main thread | ||
| 697 | + | ||
| 698 | +### Response Status Checking | ||
| 699 | + | ||
| 700 | +Different response structures have different status locations: | ||
| 701 | +```swift | ||
| 702 | +// Standard context responses (after transformation): | ||
| 703 | +response["status"] as? Int == 1 | ||
| 704 | +response["MAPP_CAMPAIGNING-status"] as? Int == 1 | ||
| 705 | +response["MAPP_COUPON-status"] as? Int == 1 | ||
| 706 | +response["MAPP_SHOPS-status"] as? Int == 1 | ||
| 707 | + | ||
| 708 | +// VerifyTicketResponseModel: | ||
| 709 | +tempResponse.getStatus == 1 | ||
| 710 | +``` | ||
| 711 | + | ||
| 712 | +### Language Handling Pattern | ||
| 713 | + | ||
| 714 | +Methods with optional language default to `applicationLocale`: | ||
| 715 | +```swift | ||
| 716 | +public func getCoupons(language: String? = nil, ...) { | ||
| 717 | + let finalLanguage = language ?? self.applicationLocale | ||
| 718 | + // use finalLanguage | ||
| 719 | +} | ||
| 720 | +``` | ||
| 721 | + | ||
| 722 | +### Complete Public API Reference | ||
| 723 | + | ||
| 724 | +**Authentication:** | ||
| 725 | +- `configure(appUuid:merchantId:environment:language:)` | ||
| 726 | +- `initialize(callback:)` / `initialize() async throws` | ||
| 727 | +- `deiLogin(email:completion:failureCallback:)` / `deiLogin(email:) async throws` | ||
| 728 | +- `verifyTicket(guid:ticket:completion:)` | ||
| 729 | +- `getCosmoteUser(guid:completion:)` / `getCosmoteUser(guid:) async throws` | ||
| 730 | +- `logout(completion:)` | ||
| 731 | + | ||
| 732 | +**Campaigns:** | ||
| 733 | +- `getCampaigns(language:filters:completion:failureCallback:)` / `getCampaigns(language:filters:) async throws` | ||
| 734 | +- `getCampaignsPersonalized(language:filters:completion:failureCallback:)` / async variant | ||
| 735 | +- `getSingleCampaign(sessionUuid:completion:)` / async variant | ||
| 736 | +- `getSupermarketCampaign(language:completion:)` / async variant | ||
| 737 | + | ||
| 738 | +**Coupons:** | ||
| 739 | +- `getCoupons(language:completion:failureCallback:)` / async variant | ||
| 740 | +- `getCouponsUniversal(language:completion:failureCallback:)` | ||
| 741 | +- `getCouponSets(language:completion:failureCallback:)` / async variant | ||
| 742 | +- `getAvailableCoupons(completion:)` / async variant | ||
| 743 | +- `validateCoupon(_:completion:)` / async variant | ||
| 744 | +- `redeemCoupon(productId:productUuid:merchantId:completion:)` / async variant | ||
| 745 | + | ||
| 746 | +**User Management:** | ||
| 747 | +- `changePassword(oldPassword:newPassword:completion:)` / async variant | ||
| 748 | +- `resetPassword(email:completion:)` / async variant | ||
| 749 | +- `requestOtp(phoneNumber:completion:)` / async variant | ||
| 750 | +- `getProfile(completion:failureCallback:)` / async variant | ||
| 751 | + | ||
| 752 | +**Cards:** | ||
| 753 | +- `addCard(cardNumber:cardIssuer:cardHolder:expirationMonth:expirationYear:completion:)` / async variant | ||
| 754 | +- `getCards(completion:)` / async variant | ||
| 755 | +- `deleteCard(token:completion:)` / async variant | ||
| 756 | + | ||
| 757 | +**Transactions:** | ||
| 758 | +- `getTransactionHistory(productDetail:completion:)` / async variant | ||
| 759 | +- `getPointsHistory(completion:)` / async variant | ||
| 760 | + | ||
| 761 | +**Merchants:** | ||
| 762 | +- `getMerchants(language:categories:defaultShown:center:tags:uuid:distance:parentUuids:completion:failureCallback:)` / async variant | ||
| 763 | +- `getMerchantCategories(language:completion:failureCallback:)` / async variant | ||
| 764 | + | ||
| 765 | +**Content:** | ||
| 766 | +- `getArticles(language:categories:completion:failureCallback:)` / async variant | ||
| 767 | + | ||
| 768 | +**Market/Supermarket:** | ||
| 769 | +- `getMarketPassDetails(completion:failureCallback:)` / async variant | ||
| 770 | +- `getRedeemedSMHistory(language:completion:failureCallback:)` / async variant | ||
| 771 | +- `openSupermarketsMap(_:)` — presents map WebView | ||
| 772 | +- `openSuperMarketsFlow(_:)` — presents supermarket campaign | ||
| 773 | + | ||
| 774 | +**State:** | ||
| 775 | +- `setCampaignList(_:)` / `getCampaignList()` / `getAllCampaignList()` | ||
| 776 | +- `setCouponList(_:)` / `getCouponList()` | ||
| 777 | +- `setOldCouponList(_:)` / `getOldCouponList()` | ||
| 778 | +- `setCouponSetList(_:)` / `getCouponSetList()` | ||
| 779 | +- `setMerchantList(_:)` / `getMerchantList()` | ||
| 780 | +- `setCarouselList(_:)` / `getCarouselList()` | ||
| 781 | +- `setSupermarketCampaign(_:)` / `getSupermarketCampaign()` | ||
| 782 | +- `setMarketPassDetails(_:)` / `getMarketPassDetails()` | ||
| 783 | +- `setCCMSLoyaltyCampaigns(campaigns:)` / `getCCMSLoyaltyCampaigns()` | ||
| 784 | +- `setSeasonalList(_:)` / `getSeasonalList()` | ||
| 785 | + | ||
| 786 | +**Utilities:** | ||
| 787 | +- `constructCampaignUrl(_:)` / `constructCampaignParams(_:)` / `constructCampaignParams(campaign:isMap:)` | ||
| 788 | +- `constructCcmsUrl(_:)` | ||
| 789 | +- `showDialog(_:_:_:)` | ||
| 790 | +- `getMarketPassMapUrl()` | ||
| 791 | +- `getNetworkStatus()` — returns 1 (connected) or 0 (disconnected) | ||
| 792 | +- `updateRefreshToken(accessToken:refreshToken:)` | ||
| 793 | +- `updateDeviceToken(_:)` | ||
| 794 | + | ||
| 795 | +## 14. Coding Standards & Conventions | ||
| 796 | + | ||
| 797 | +### Naming Conventions | ||
| 798 | +- **Files:** PascalCase matching the primary type (`CampaignItemModel` → `Campaign.swift`) | ||
| 799 | +- **Classes/Structs:** PascalCase (`CampaignItemModel`, `NetworkService`) | ||
| 800 | +- **Private properties:** snake_case for model fields (`category_title`, `banner_img`) | ||
| 801 | +- **Public accessors:** underscore prefix (`_category_title`, `_banner_img`) | ||
| 802 | +- **Constants:** PascalCase for enum cases, camelCase for static properties | ||
| 803 | +- **Endpoint enum cases:** camelCase (`getCampaigns`, `verifyTicket`) | ||
| 804 | + | ||
| 805 | +### Logging Convention | ||
| 806 | +Emoji-prefixed print statements throughout the codebase: | ||
| 807 | +``` | ||
| 808 | +✅ Success operations | ||
| 809 | +❌ Errors/failures | ||
| 810 | +⚠️ Warnings | ||
| 811 | +🔴 Critical errors | ||
| 812 | +🟡 Token should refresh | ||
| 813 | +🟢 Token valid | ||
| 814 | +🔐 Token/auth operations | ||
| 815 | +🔒 Encryption operations | ||
| 816 | +🔓 Decryption operations | ||
| 817 | +🔑 Key operations | ||
| 818 | +🗄️ Database operations | ||
| 819 | +🗑️ Deletion/clearing | ||
| 820 | +📊 Analytics/stats | ||
| 821 | +📤 Request logging | ||
| 822 | +📥 Response logging | ||
| 823 | +🔗 URL operations | ||
| 824 | +🔄 Refresh/retry/update | ||
| 825 | +🚨 Circuit breaker | ||
| 826 | +🧪 Test operations | ||
| 827 | +💡 Suggestions | ||
| 828 | +⏱️ Timing | ||
| 829 | +🔧 Configuration | ||
| 830 | +🏭 Production | ||
| 831 | +🚦 Request queue | ||
| 832 | +⚛️ Atomic operations | ||
| 833 | +``` | ||
| 834 | + | ||
| 835 | +### Access Control | ||
| 836 | +- **`public`:** All types, methods, and properties intended for client use | ||
| 837 | +- **`public final class`:** WarplySDK, NetworkService (prevent subclassing) | ||
| 838 | +- **`private`/`internal`:** Implementation details, state management | ||
| 839 | +- **`actor`:** All security-critical components (TokenRefreshManager, FieldEncryption, KeychainManager) | ||
| 840 | + | ||
| 841 | +### Error Handling Convention | ||
| 842 | +- Errors are logged immediately at the point of occurrence | ||
| 843 | +- Every error posts a Dynatrace analytics event | ||
| 844 | +- Callback methods return `nil` on failure, async methods `throw` | ||
| 845 | +- `handleError()` private method centralizes error conversion and logging | ||
| 846 | + | ||
| 847 | +### UserDefaults Keys | ||
| 848 | +- `appUuidUD` — App UUID | ||
| 849 | +- `merchantIdUD` — Merchant ID | ||
| 850 | +- `languageUD` — Language | ||
| 851 | +- `isDarkModeEnabledUD` — Dark mode flag | ||
| 852 | +- `environmentUD` — "development" or "production" | ||
| 853 | +- `trackersEnabled` — Analytics tracking flag | ||
| 854 | +- `device_token` — Push notification device token | ||
| 855 | + | ||
| 856 | +### Configuration Static Properties | ||
| 857 | +```swift | ||
| 858 | +Configuration.baseURL // Current base URL | ||
| 859 | +Configuration.host // Current host | ||
| 860 | +Configuration.errorDomain // Error domain (same as host) | ||
| 861 | +Configuration.merchantId // Merchant ID | ||
| 862 | +Configuration.language // "el" or "en" | ||
| 863 | +Configuration.verifyURL // Verification URL | ||
| 864 | +``` | ||
| 865 | + | ||
| 866 | +## 15. Dependencies & Distribution | ||
| 867 | + | ||
| 868 | +### External Dependencies | ||
| 869 | + | ||
| 870 | +| Dependency | Version | Purpose | | ||
| 871 | +|-----------|---------|---------| | ||
| 872 | +| `SQLite.swift` | `0.12.2` (exact) | SQLite database access (raw SQL, not ORM) | | ||
| 873 | +| `SwiftEventBus` | `5.0.0+` | Event bus for client-facing events (backward compatibility) | | ||
| 874 | +| `RSBarcodes_Swift` | `5.2.0+` | Barcode generation for coupon display | | ||
| 875 | + | ||
| 876 | +### Distribution Methods | ||
| 877 | + | ||
| 878 | +**CocoaPods** (`SwiftWarplyFramework.podspec`): | ||
| 879 | +```ruby | ||
| 880 | +pod 'SwiftWarplyFramework', '~> 2.3.0' | ||
| 881 | +``` | ||
| 882 | +- Resource bundles: `ResourcesBundle` containing `.xcassets` and `.otf` fonts | ||
| 883 | +- Source files: `SwiftWarplyFramework/SwiftWarplyFramework/**/*.{h,m,swift,xib,storyboard}` | ||
| 884 | +- Excludes: build artifacts, xcodeproj files | ||
| 885 | + | ||
| 886 | +**Swift Package Manager** (`Package.swift`): | ||
| 887 | +```swift | ||
| 888 | +.package(url: "https://git.warp.ly/open-source/warply_sdk_framework.git", from: "2.3.0") | ||
| 889 | +``` | ||
| 890 | +- Target path: `SwiftWarplyFramework/SwiftWarplyFramework` | ||
| 891 | +- Excludes: `Helpers/WarplyReactMethods.h`, `Helpers/WarplyReactMethods.m`, `Info.plist` | ||
| 892 | +- Resources: `.process()` for xcassets, fonts, storyboards, XIBs | ||
| 893 | + | ||
| 894 | +### System Frameworks Used | ||
| 895 | +- `Foundation` — Core types | ||
| 896 | +- `UIKit` — UI components | ||
| 897 | +- `Network` — `NWPathMonitor` for connectivity | ||
| 898 | +- `CryptoKit` — AES-256-GCM encryption | ||
| 899 | +- `Security` — Keychain Services | ||
| 900 | +- `CommonCrypto` — SHA256 hashing for loyalty-signature header | ||
| 901 | + | ||
| 902 | +## 16. Common Tasks & Recipes | ||
| 903 | + | ||
| 904 | +### Adding a New API Endpoint | ||
| 905 | + | ||
| 906 | +1. **Add endpoint case** in `Endpoints.swift`: | ||
| 907 | + ```swift | ||
| 908 | + case myNewEndpoint(param1: String, param2: Int) | ||
| 909 | + ``` | ||
| 910 | + | ||
| 911 | +2. **Add path** in the `path` computed property: | ||
| 912 | + ```swift | ||
| 913 | + case .myNewEndpoint: | ||
| 914 | + return "/oauth/{appUUID}/context" // or appropriate path | ||
| 915 | + ``` | ||
| 916 | + | ||
| 917 | +3. **Add method** (most are POST): | ||
| 918 | + ```swift | ||
| 919 | + case .myNewEndpoint: | ||
| 920 | + return .POST | ||
| 921 | + ``` | ||
| 922 | + | ||
| 923 | +4. **Add parameters** with correct wrapper key: | ||
| 924 | + ```swift | ||
| 925 | + case .myNewEndpoint(let param1, let param2): | ||
| 926 | + return ["consumer_data": ["action": "my_action", "param1": param1, "param2": param2]] | ||
| 927 | + ``` | ||
| 928 | + | ||
| 929 | +5. **Set auth type** in `authType`: | ||
| 930 | + ```swift | ||
| 931 | + case .myNewEndpoint: | ||
| 932 | + return .bearerToken // or .standard | ||
| 933 | + ``` | ||
| 934 | + | ||
| 935 | +6. **Set category** in `category`: | ||
| 936 | + ```swift | ||
| 937 | + case .myNewEndpoint: | ||
| 938 | + return .authenticatedContext | ||
| 939 | + ``` | ||
| 940 | + | ||
| 941 | +7. **Add method** in `WarplySDK.swift` (both callback + async variants): | ||
| 942 | + ```swift | ||
| 943 | + // Callback version | ||
| 944 | + public func myNewMethod(param1: String, param2: Int, completion: @escaping (MyModel?) -> Void, failureCallback: @escaping (Int) -> Void) { | ||
| 945 | + Task { | ||
| 946 | + do { | ||
| 947 | + let endpoint = Endpoint.myNewEndpoint(param1: param1, param2: param2) | ||
| 948 | + let response = try await networkService.requestRaw(endpoint) | ||
| 949 | + await MainActor.run { | ||
| 950 | + if response["status"] as? Int == 1 { | ||
| 951 | + let dynatraceEvent = LoyaltySDKDynatraceEventModel() | ||
| 952 | + dynatraceEvent._eventName = "custom_success_my_new_method_loyalty" | ||
| 953 | + dynatraceEvent._parameters = nil | ||
| 954 | + self.postFrameworkEvent("dynatrace", sender: dynatraceEvent) | ||
| 955 | + // Parse and return result | ||
| 956 | + completion(parsedResult) | ||
| 957 | + } else { | ||
| 958 | + let dynatraceEvent = LoyaltySDKDynatraceEventModel() | ||
| 959 | + dynatraceEvent._eventName = "custom_error_my_new_method_loyalty" | ||
| 960 | + dynatraceEvent._parameters = nil | ||
| 961 | + self.postFrameworkEvent("dynatrace", sender: dynatraceEvent) | ||
| 962 | + failureCallback(-1) | ||
| 963 | + } | ||
| 964 | + } | ||
| 965 | + } catch { | ||
| 966 | + await MainActor.run { | ||
| 967 | + self.handleError(error, context: "myNewMethod", endpoint: "myNewEndpoint", failureCallback: failureCallback) | ||
| 968 | + } | ||
| 969 | + } | ||
| 970 | + } | ||
| 971 | + } | ||
| 972 | + | ||
| 973 | + // Async variant | ||
| 974 | + public func myNewMethod(param1: String, param2: Int) async throws -> MyModel { | ||
| 975 | + return try await withCheckedThrowingContinuation { continuation in | ||
| 976 | + myNewMethod(param1: param1, param2: param2, completion: { result in | ||
| 977 | + if let result = result { continuation.resume(returning: result) } | ||
| 978 | + else { continuation.resume(throwing: WarplyError.networkError) } | ||
| 979 | + }, failureCallback: { errorCode in | ||
| 980 | + continuation.resume(throwing: WarplyError.unknownError(errorCode)) | ||
| 981 | + }) | ||
| 982 | + } | ||
| 983 | + } | ||
| 984 | + ``` | ||
| 985 | + | ||
| 986 | +### Adding a New Data Model | ||
| 987 | + | ||
| 988 | +1. Create file in `models/` directory | ||
| 989 | +2. Use `class` (not struct) for consistency with existing models | ||
| 990 | +3. Implement `init(dictionary: [String: Any])` for JSON parsing | ||
| 991 | +4. Use `_fieldName` accessor pattern for public properties | ||
| 992 | +5. Handle optional fields with `?? ""` or `?? 0` defaults | ||
| 993 | +6. Clean URLs with `cleanEscapedUrl()` if applicable | ||
| 994 | + | ||
| 995 | +### Adding a New Database Table | ||
| 996 | + | ||
| 997 | +1. Increment `currentDatabaseVersion` in `DatabaseManager.swift` | ||
| 998 | +2. Add new version to `supportedVersions` array | ||
| 999 | +3. Create migration function `performMigrationToV{N}()` | ||
| 1000 | +4. Add case to `performMigration(to:)` switch | ||
| 1001 | +5. Create table using raw SQL (`try db.execute(...)`) | ||
| 1002 | + | ||
| 1003 | +### Adding a New Event Type | ||
| 1004 | + | ||
| 1005 | +1. Create struct conforming to `WarplyEvent` in `EventDispatcher.swift` | ||
| 1006 | +2. Add convenience method in `EventDispatcher` extension | ||
| 1007 | +3. Add `InternalFrameworkEvent` case in `WarplySDK.swift` if it's internal | ||
| 1008 | +4. Post using `postFrameworkEvent()` or `postInternalFrameworkEvent()` | ||
| 1009 | +5. **Always post to both SwiftEventBus and EventDispatcher** | ||
| 1010 | + | ||
| 1011 | +### Adding a New Configuration Option | ||
| 1012 | + | ||
| 1013 | +1. Add property to appropriate config struct (`WarplyDatabaseConfig`, etc.) | ||
| 1014 | +2. Add validation in `validate()` method | ||
| 1015 | +3. Add to `getSummary()` return dictionary | ||
| 1016 | +4. Add to the preset configurations (`.development`, `.production`, etc.) | ||
| 1017 | +5. Ensure Codable compliance (add to CodingKeys if needed) | ||
| 1018 | + | ||
| 1019 | +## 17. Error Handling | ||
| 1020 | + | ||
| 1021 | +### Error Types | ||
| 1022 | + | ||
| 1023 | +**`WarplyError`** (public, SDK-level): | ||
| 1024 | +| Case | Code | Description | | ||
| 1025 | +|------|------|-------------| | ||
| 1026 | +| `.networkError` | -1000 | Generic network failure | | ||
| 1027 | +| `.invalidResponse` | -1001 | Invalid/unparseable response | | ||
| 1028 | +| `.authenticationFailed` | 401 | Auth failure | | ||
| 1029 | +| `.dataParsingError` | -1002 | JSON parsing failure | | ||
| 1030 | +| `.serverError(Int)` | (code) | HTTP server error | | ||
| 1031 | +| `.noInternetConnection` | -1009 | No network connectivity | | ||
| 1032 | +| `.requestTimeout` | -1001 | Request timed out | | ||
| 1033 | +| `.unknownError(Int)` | (code) | Catch-all | | ||
| 1034 | + | ||
| 1035 | +**`NetworkError`** (public, network-level): | ||
| 1036 | +| Case | Code | Description | | ||
| 1037 | +|------|------|-------------| | ||
| 1038 | +| `.invalidURL` | -1001 | Bad URL construction | | ||
| 1039 | +| `.noData` | -1002 | Empty response body | | ||
| 1040 | +| `.decodingError(Error)` | -1003 | JSON decode failure | | ||
| 1041 | +| `.serverError(Int)` | (code) | HTTP 4xx/5xx | | ||
| 1042 | +| `.networkError(Error)` | (varies) | URLSession error | | ||
| 1043 | +| `.authenticationRequired` | 401 | Token needed/expired | | ||
| 1044 | +| `.invalidResponse` | -1004 | Non-JSON response | | ||
| 1045 | + | ||
| 1046 | +**`DatabaseError`** (internal): | ||
| 1047 | +- `.connectionNotAvailable` — DB not initialized | ||
| 1048 | +- `.tableCreationFailed` — Schema creation error | ||
| 1049 | +- `.queryFailed(String)` — SQL execution error | ||
| 1050 | +- `.migrationFailed(String)` — Version migration error | ||
| 1051 | +- `.corruptedDatabase` — Integrity check failed | ||
| 1052 | + | ||
| 1053 | +**`TokenRefreshError`** (public): | ||
| 1054 | +- `.noTokensAvailable` — No tokens in DB | ||
| 1055 | +- `.invalidRefreshToken` — Bad refresh token | ||
| 1056 | +- `.networkError(Error)` — Network failure during refresh | ||
| 1057 | +- `.serverError(Int)` — Server error during refresh | ||
| 1058 | +- `.maxRetriesExceeded` — All retry attempts failed | ||
| 1059 | +- `.refreshInProgress` — Concurrent refresh attempt | ||
| 1060 | +- `.invalidResponse` — Bad refresh response | ||
| 1061 | + | ||
| 1062 | +**`EncryptionError`** (internal): | ||
| 1063 | +- `.invalidKey` (4001), `.encryptionFailed` (4002), `.decryptionFailed` (4003), `.invalidData` (4004), `.keyGenerationFailed` (4005) | ||
| 1064 | + | ||
| 1065 | +**`ConfigurationError`** (public): | ||
| 1066 | +- `.invalidRefreshThreshold`, `.invalidRetryAttempts`, `.retryDelaysMismatch`, `.invalidTimeout`, `.invalidCacheSize`, `.invalidCircuitBreakerThreshold`, etc. | ||
| 1067 | +- All include `errorDescription` and `recoverySuggestion` | ||
| 1068 | + | ||
| 1069 | +### Error Flow | ||
| 1070 | +1. Network/DB error occurs → caught in `catch` block | ||
| 1071 | +2. `handleError()` called → converts to `WarplyError` via `convertNetworkError()` | ||
| 1072 | +3. Error logged with context via `logError()` | ||
| 1073 | +4. Dynatrace error event posted via `postErrorAnalytics()` | ||
| 1074 | +5. `failureCallback(errorCode)` called on main thread | ||
| 1075 | + | ||
| 1076 | +## 18. Testing | ||
| 1077 | + | ||
| 1078 | +### Mock Support | ||
| 1079 | + | ||
| 1080 | +**`MockNetworkService`** (available in `#if DEBUG`): | ||
| 1081 | +```swift | ||
| 1082 | +let mockService = MockNetworkService() | ||
| 1083 | +mockService.setMockResponse(["status": 1, "result": [...]], for: .getCampaigns(...)) | ||
| 1084 | +mockService.setShouldFail(true, with: .networkError(...)) | ||
| 1085 | +``` | ||
| 1086 | + | ||
| 1087 | +### Test Configuration | ||
| 1088 | +Use `WarplyConfiguration.testing` preset: | ||
| 1089 | +- Encryption disabled for simpler test setup | ||
| 1090 | +- 5-second request timeout for fast tests | ||
| 1091 | +- Single retry attempt | ||
| 1092 | +- 0.1s retry delay | ||
| 1093 | +- Analytics and auto-registration disabled | ||
| 1094 | + | ||
| 1095 | +### SQLite Test | ||
| 1096 | +```swift | ||
| 1097 | +let result = await WarplySDK.shared.testSQLiteConnection() | ||
| 1098 | +// Returns true if SQLite.swift is working correctly | ||
| 1099 | +``` | ||
| 1100 | + | ||
| 1101 | +### Database Diagnostics | ||
| 1102 | +```swift | ||
| 1103 | +// Check integrity | ||
| 1104 | +let isIntact = try await DatabaseManager.shared.checkDatabaseIntegrity() | ||
| 1105 | + | ||
| 1106 | +// Get stats | ||
| 1107 | +let stats = try await DatabaseManager.shared.getDatabaseStats() | ||
| 1108 | +// Returns (tokensCount: Int, eventsCount: Int, poisCount: Int) | ||
| 1109 | + | ||
| 1110 | +// Get version info | ||
| 1111 | +let versionInfo = try await DatabaseManager.shared.getDatabaseVersionInfo() | ||
| 1112 | + | ||
| 1113 | +// Token validation | ||
| 1114 | +let validationResult = try await DatabaseManager.shared.validateStoredTokens() | ||
| 1115 | + | ||
| 1116 | +// Token status | ||
| 1117 | +let status = try await DatabaseManager.shared.getTokenStatus() | ||
| 1118 | +``` | ||
| 1119 | + | ||
| 1120 | +### Configuration Diagnostics | ||
| 1121 | +```swift | ||
| 1122 | +let summary = WarplySDK.shared.getConfigurationSummary() | ||
| 1123 | +let config = WarplySDK.shared.getCurrentConfiguration() | ||
| 1124 | +``` | ||
| 1125 | + | ||
| 1126 | +### Encryption Diagnostics | ||
| 1127 | +```swift | ||
| 1128 | +let encryptionStats = try await DatabaseManager.shared.getEncryptionStats() | ||
| 1129 | +let isEncryptionWorking = await DatabaseManager.shared.validateEncryptionSetup() | ||
| 1130 | +let keychainDiag = await KeychainManager.shared.getDiagnosticInfo() | ||
| 1131 | +``` | ||
| 1132 | + | ||
| 1133 | +--- | ||
| 1134 | + | ||
| 1135 | +## Quick Reference Card | ||
| 1136 | + | ||
| 1137 | +| What | Where | Key | | ||
| 1138 | +|------|-------|-----| | ||
| 1139 | +| All public APIs | `Core/WarplySDK.swift` | `WarplySDK.shared` | | ||
| 1140 | +| API routes | `Network/Endpoints.swift` | `Endpoint` enum | | ||
| 1141 | +| HTTP client | `Network/NetworkService.swift` | `NetworkService.shared` | | ||
| 1142 | +| Token refresh | `Network/TokenRefreshManager.swift` | `TokenRefreshManager.shared` | | ||
| 1143 | +| Token storage | `Database/DatabaseManager.swift` | `DatabaseManager.shared` | | ||
| 1144 | +| Token model | `models/TokenModel.swift` | `TokenModel` struct | | ||
| 1145 | +| Encryption | `Security/FieldEncryption.swift` | `FieldEncryption.shared` | | ||
| 1146 | +| Keychain | `Security/KeychainManager.swift` | `KeychainManager.shared` | | ||
| 1147 | +| Events | `Events/EventDispatcher.swift` | `EventDispatcher.shared` | | ||
| 1148 | +| Config | `Configuration/WarplyConfiguration.swift` | `WarplyConfiguration` struct | | ||
| 1149 | +| Campaigns | `models/Campaign.swift` | `CampaignItemModel` class | | ||
| 1150 | +| Coupons | `models/Coupon.swift` | `CouponItemModel` class | | ||
| 1151 | + | ||
| 1152 | +**Critical Rules for AI Agents:** | ||
| 1153 | +1. **Always maintain dual event posting** (SwiftEventBus + EventDispatcher) | ||
| 1154 | +2. **Always post Dynatrace analytics** on both success and failure paths | ||
| 1155 | +3. **Always provide both callback AND async/await** variants for public methods | ||
| 1156 | +4. **Always dispatch completion callbacks on main thread** via `MainActor.run` | ||
| 1157 | +5. **Use raw SQL** in DatabaseManager (not SQLite.swift Expression builders) | ||
| 1158 | +6. **Never log sensitive data** (tokens, passwords, card numbers) — use masking | ||
| 1159 | +7. **All security components must be actors** (not classes) | ||
| 1160 | +8. **Models use `class` not `struct`** with `_fieldName` accessor pattern | ||
| 1161 | +9. **Token storage is in database only** — never in UserDefaults | ||
| 1162 | +10. **All response parsing must handle the context transformation** pattern |
-
Please register or login to post a comment