# PlantTogether — Master Feature Spec (reverse-engineered) > Source of truth: forest-ios source + stforestkit KMP source. Reverse-engineered, code-verified. > Generated 2026-06-30. Every claim carries a `file:line` reference. REFUTED claims dropped; IMPRECISE claims corrected. > > **Sprint 49 · value team.** Purpose: reference spec for the refactored PlantTogether feature in the stforestkit (KMP) implementation. This captures the *current* iOS behavior as the baseline the refactor must preserve; it is a reverse-engineered record, not a forward design. > Companion FigJam (flow diagrams): https://www.figma.com/board/09QaF41QY2cKm8iECIZ546/PlantTogether-%E2%80%94-Feature-Map --- ## 1. Overview PlantTogether ("Together" / group planting / Rooms) lets a user plant a focus session jointly with others. A **host** creates a "chartered" room, invites friends, starts the session; **guests** join via invitation, room code, or deep link. The group tree's success/failure is decided server-side; each participant's individual outcome is decided locally (give-up → chop). ### 1.1 Architecture split: iOS native vs stforestkit - **The LIVE iOS path is entirely legacy native code.** A singleton Obj-C `TogetherDataManager` holds the in-memory active room and polls `/api/v1/rooms/{id}`. Create/join/invite/kick/start/leave/chop all go through native `HttpRequestHelper` (Obj-C) or the app's own Moya `TogetherRoomEndpoint` (`.join` only). It does **not** use stforestkit. - **The stforestkit shared code** (`DefaultSyncRoomRecordsUseCase`, `DefaultChopFailedRoomsUseCase`, `RoomEndpoints`, `RoomMapper`, `RoomDto`) is a KMP reimplementation of the same server contract, wired in `SyncModule`, but its `LocalRoomRepository`/`LocalLastSyncRoomTimeRepository` bindings are **not provided** anywhere reachable on iOS (only `RemoteRoomRepository` is bound in `SyncModule.kt:145`). On iOS the kit path is effectively the Android/future implementation and is **not the running code**. - Ref: `stforestkit SyncModule.kt:145,212-217,285-291`; kit room consumer `DefaultRemoteRoomRepository.kt:15,20` uses only `getRooms` + `chopRoom`. - **stforestkit shared room code is type-agnostic plumbing** — `RoomMapper.kt:11-46` copies `roomType` verbatim, `RoomInfo.kt:10`/`RoomDto.kt:13` carry it as an opaque String, sync/chop use cases never branch on it. The one type-specific literal: `RoomEndpoints.kt:29,205` default `roomType = "chartered"`. - **stforestkit `ParticipantMapper.toDomain()` hardcodes `isJoined=true`, `isChopper=false`** (`ParticipantMapper.kt:13-22`); the kit domain `Participant` cannot represent pending/chopper state. That state exists only in iOS `TogetherParticipantModel` (`isPending` local-only; `isChopper` derived from `failAt`). Ref: `TogetherParticipantModel.h:12-14`. ### 1.2 The three room types All three share one data model and one `/rooms` endpoint set; they differ only in how participants are populated and how success/failure is computed. | Type | Created on iOS? | Population | |---|---|---| | `chartered` | YES (hardcoded) | Host invites friends | | `shuttle_bus` | NO (receive/join only) | Server random match + injected dummy users, `scheduled_start_time` countdown, `success_rate_threshold` tolerance | | `birthday_2019_random` | NO (receive/join only) | Server-side; distinct only in invitation cake-box image + notification title | - iOS create **always** sends `room_type:"chartered"` (`TogetherDataManager.m:81`); update also hardcodes chartered (`TogetherDataManager.m:138`). No client UI/path creates the other two. - Parser: `shuttle_bus`→ShuttleBus, `birthday_2019_random`→Birthday2019Random, anything else / missing → Chartered. Ref: `TogetherRoomModel.m:77-88`. Enum at `TogetherRoomModel.h:5-9`. - **No client-side matchmaking** for any type. Random matching, dummy-user injection, `scheduled_start_time`, `success_rate_threshold` are all server-set; client only parses/displays. Ref: `TogetherRoomModel.m:65-88`; `RoomDto.kt:13-15`. ### 1.3 Glossary - **chopper** — the participant who caused/triggered the tree failure. `chopper_user_id` is a single scalar (`TogetherRoomModel.h:22`); only one chopper is modeled, though multiple participants can have `failed_at`. - **needsChopper** — local CoreData flag on a `Plant`; set on give-up, drives a deferred `PUT /rooms/{id}/chop` during sync upload. - **usageLimitedInTogetherMode** — per-tree-type boolean true only for TinyTAN/BTS types `TreeType89...TreeType95` (`TreeType.swift:425`). Not a quota/counter. - **isCreatedByCurrentUser** — local-only host flag; not in JSON, not copied by `copyValuesFrom`. Reliable only within the original create/join session lifetime. `duplicate()` re-copies it in-memory (`TogetherRoomModel.m:218`). - **participantsFinalized** — `!isCreatedByCurrentUser || startTime != nil` (`TogetherRoomModel.m:199-203`); true for guests, or host after start. Suppresses kick action sheet. - **success_rate_threshold** — percent integer (`80 = 80%`), default 100 when absent (`TogetherRoomModel.m:67-71`). Response-only; never sent on create. - **TinyTAN.isAvailable** — region + date-window + global-server gate (`TinyTAN.swift:5-38`). --- ## 1.4 Feature map ```mermaid flowchart LR toggle["Toggle Together mode"] --> walkthrough["Walkthrough (first time)"] walkthrough --> setup["Pick tree + duration"] toggle --> setup setup -->|"tap create"| create["Create-room flow"] create -->|"POST 201"| outbound["Invitation outbound (invite friends)"] create -->|"usage-limited TinyTAN host"| usage["Usage-limits gate"] outbound -->|"server push"| inbound["Invitation inbound (receive)"] joinBtn["Tap Join"] --> joincode["Join-by-code flow"] deeplink["Deep link / room code"] --> joincode inbound -->|"accept"| joined["In-session live state"] inbound -->|"decline"| declined["Declined (room cleared)"] inbound -->|"TinyTAN not owned"| usage joincode -->|"200 join"| joined joincode -->|"483 not owned"| usage joincode -->|"400/404/423/498/error"| joinfail["Join failed (inline error)"] create -->|"POST 201"| joined joined -->|"timer completes"| success["Finish: success"] joined -->|"give up / chopped"| fail["Finish: failure (chop)"] joined -->|"host leave / kicked / no host"| left["Room gone / left"] joined -->|"relaunch / sync"| restore["Sync-restore"] restore --> joined success --> done["Outcome reconciled (server + local)"] fail --> done ``` --- ## 2. Sub-flow: Create-room ### Summary Creating a Together room on iOS is hardcoded to "chartered". User enables Together mode (`PlantingModeSelectionViewController` sets `cnstPreferenceIsTogetherModeActive`), picks tree + duration, taps the action button (`create_button_title`). With no room yet this fires `PlantingManageViewModel.onCreateRoomSubject`, routed to `TogetherFriendPickerViewController`. The actual `POST /api/v1/rooms` is made by Obj-C `TogetherDataManager.createRoomWithTreeType:duration:` sending `{tree_type, target_duration, room_type:"chartered"}` via raw `HttpRequestHelper` (NOT Moya `TogetherRoomEndpoint`, NOT kit). On 201 the host's local room is built, marked `isCreatedByCurrentUser`, a self host-participant synthesized, auto-polling starts, friends invited. ### Entry points - Toggle Together mode in selector — `PlantingModeSelectionViewController.swift:130`. - First-ever entry → walkthrough then notification-permission — `MainViewController.m:2513` `didChangeSelectedMode` → `2518`. - Action button in Planting Manage (Together mode, no room) — `PlantingManageViewController.swift:315`. - Alternate: Start footer / Main header create buttons — `StartFooterViewController.swift:228`; `MainHeaderViewController.swift:136`. - `onCreateRoomSubject` → `showTogetherFriendPicker` — `MainViewController.swift:691-695` → `:2638`. - Friend picker action button — `TogetherFriendPickerViewController.m:132` → `:155`. ```mermaid stateDiagram-v2 [*] --> SoloMode SoloMode --> TogetherMode : "Toggle Together" TogetherMode --> Walkthrough : "first-ever entry (walkthrough not played)" TogetherMode --> PlantingManage Walkthrough --> PlantingManage PlantingManage --> UnlockPaywall : "tap locked tree (isPlantUnlocked==false)" PlantingManage --> FriendPicker : "tap create (room==nil && unlocked)" FriendPicker --> NeedLoginDialog : "tap create (not registered)" FriendPicker --> Creating : "tap create (registered, room==nil)" Creating --> InvitingFriends : "POST 201 (dict)" Creating --> ErrorUnknown : "non-201 / non-dict" Creating --> ErrorNotAuthenticated : "403" Creating --> ErrorMaintenance : "5xx + maintenance flag" InvitingFriends --> HostRoomStartState : "all invites settle (or none)" HostRoomStartState --> TinyTANLimitDialog : "treeType usage-limited && not shown" HostRoomStartState --> [*] TinyTANLimitDialog --> [*] ``` ### Happy path 1. User enables Together mode; selector writes UserDefaults flag — `PlantingModeSelectionViewController.swift:130`; `PlantingModeProvider.swift:29-31`. 2. First-ever: present `TogetherWalkthroughViewController`, then notification permission on dismiss — `MainViewController.m:2516-2527`. 3. Select tree species (host list filtered to unlocked; TinyTAN blocked) — `PlantingSetupViewModel.swift:92-95,130-136`. 4. Select duration (circle-slider) — `PlantingSetupViewModel.swift:111-118`. 5. Tap create button → `onCreateRoomSubject`, close sheet — `PlantingManageViewController.swift:315-317`. 6. `showTogetherFriendPicker` segue passes `_treeType`, `_totalPlantTime`, `currentRoom=nil`, completionBlock — `MainViewController.m:2312-2320`. 7. Friend picker loads `GET /api/v1/friends`; title/button = `create_new_room_title`/`create_button_title` — `TogetherFriendPickerViewController.m:40-54,66-126`. 8. Optionally select friends, tap create — `:132-157`. 9. `currentRoom==nil`: start HUD + `createRoomWithTreeType:duration:` — `:153-156`. 10. `POST /api/v1/rooms {tree_type, target_duration, room_type:"chartered"}` via raw `HttpRequestHelper` — `TogetherDataManager.m:76-82`. 11. Server returns 201 with room JSON — `TogetherDataManager.m:84`. 12. Parse → set `isCreatedByCurrentUser=YES`, synthesize self host participant (`isHost=YES,isPending=NO`), assign `_room` — `TogetherDataManager.m:85-96`. Host participant is **synthesized locally** from `UserManager.userId`, not taken from the server participants array (`:91-95`). 13. Post `DidCreateRoomNotification`, enable auto-polling — `:97-100`. 14. Friend picker `didCreateRoom:` builds invited (pending) list, calls `invite:` — `:161-172`. 15. MainVC `didCreateRoom:` logs tracking + host-tag tip — `MainViewController.m:2594-2602`. 16. `invite:` → `PUT /api/v1/rooms/{id}/invite {user_id}` per friend; posts `DidSendInvitations` when all settle — `TogetherDataManager.m:398-440`. 17. `didInviteFriends:` appends invited participants, runs completionBlock, navigates back, stops HUD — `:174-198`. 18. completionBlock: `setRoom(newRoom)` then `showTinyTANTogetherLimitDialogIfNeeded` — `MainViewController.m:2317-2320`. 19. `setRoom` (startTime nil, no chopper) → StartState, host room UI, share-code button, participant avatars — `MainViewController.m:3046-3113`. 20. If tree is TinyTAN usage-limited, one-time limit dialog — `MainViewController.swift:1520-1537`. ### States - Walkthrough (first time) — `MainViewController.m:2516-2527`. - Planting Manage sheet (host config; usage-limited TinyTAN disabled) — `PlantingManageViewController.swift:97,146,289-323`. - Friend picker loading / not-registered / empty-list / maintenance-error / creating — `TogetherFriendPickerViewController.m:67-124,154,197`. - Host room start-state on Main — `MainViewController.m:3077-3103`; `:2628-2636`. - TinyTAN limit dialog — `MainViewController.swift:1520-1537`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | Solo mode | Toggle Together | Together mode | — | | Together mode | First-ever entry | Walkthrough | `cnstPreferenceTogetherWalkthroughPlayed == false` | | Planting Manage | Tap create | Friend picker | `isInTogetherMode && room==nil && isPlantUnlocked` | | Planting Manage | Tap on locked tree | Unlock/paywall/store | `isPlantUnlocked == false` | | Friend picker | Tap create (registered) | Creating | `isRegisteredUser && currentRoom==nil` | | Friend picker | Tap create (not registered) | Need-login dialog | `isRegisteredUser == false` | | Creating | POST 201 | Inviting friends | `responseObject is NSDictionary` | | Creating | POST non-201/non-dict | Error alert (unknown) | — | | Creating | POST 403 | Error alert (not authenticated) | — | | Creating | POST 5xx + maintenance | Error alert (maintenance) | `CloudConfig.isServerUnderMaintenance` | | Inviting | All invites settle (or none) | Host room start-state | — | | Host room start-state | setRoom completion | TinyTAN limit dialog | `room.treeType.usageLimitedInTogetherMode && !alreadyShown` | ### Edge cases - **POST non-201 (e.g. 200, other 4xx via failure block)** → `TogetherDataManagerErrorTypeUnknown` → `fail_message_together_unknown` alert. Success-block HUD is not stopped (HUD stops only on invite completion which never runs), but the ERROR path's HUD **is** stopped via `didRecieveTogetherError:` (`shouldStopLoadingView=YES`, `MainViewController.m:3208-3209`). Refs: `TogetherDataManager.m:84-108`; failure block 403/5xx at `:110-118`; alert at `MainViewController.m:3144-3145`. - **POST 403** → `NotAuthenticated` → `fail_message_log_in_first` — `TogetherDataManager.m:112-113`; `MainViewController.m:3147-3148`. - **POST 5xx + maintenance flag true** → `MaintenanceInProgress`; flag false → Unknown — `TogetherDataManager.m:110-118`. - **Server omits success_rate_threshold** → defaults 100 — `TogetherRoomModel.m:67-71`. (At 100, `maxNumberFailedParticipantsAllowed` = `N*0/100 + 1 = 1`, i.e. tolerates exactly one failure, not zero.) - **Unknown tree_type** → fallback TreeType1 (cedar) if duration ≥ `cnstMinPlantTimeForFirstBigTree` (1500s), else TreeType7 (bush) — `TogetherRoomModel.m:47-53`. - **room_type other than chartered (parser only)** → maps as in §1.2 — `TogetherRoomModel.m:77-88`. - **No friends selected** → room still POSTs; `invite:` with empty array immediately posts `DidSendInvitations` (does not early-return; empty loop is harmless), lands in room with just host — `TogetherFriendPickerViewController.m:161-172`; `TogetherDataManager.m:401-405`. - **Individual invite PUT fails/non-200** → that friend's status set NO; aggregation completes once all settle; failures silently dropped (userInfo:nil); failed invitees still optimistically appended (gated on isPending + not-already-participant, NOT on success) — `TogetherDataManager.m:427-438`; `TogetherFriendPickerViewController.m:174-196`. - **GET /friends 403** → `popToMainView` — `TogetherFriendPickerViewController.m:110-111`. - **Tap create while not registered** → `showNeedLoginDialog`, no room — `:133-136`. - **Host actively switches to a usage-limited TinyTAN in setup** → blocked (`attemptedToSelectDisabledSpecies`), not selected; host list filtered unlocked-only — `PlantingSetupViewModel.swift:92-95,132`. This blocks only the *switch action*. - **LOOPHOLE — host CAN create/host with a TinyTAN tree if it was already the selected species before hosting.** The block fires only on an active switch (`PlantingSetupViewModel.swift:92`); an already-selected TinyTAN renders `.selected` because `isSelected` precedes `isUsageLimited` in the cell (`PlantingSetupViewController.swift:241`) and persists as the selection; and `createRoomWithTreeType`/`updateTreeType` send the treeType with NO usage-limited guard (`TogetherDataManager.m:76-82,130-158`). So a solo-selected owned TinyTAN carries straight into room creation. (Corrects the earlier "host cannot use TinyTAN" claim.) - **Room created with TinyTAN usage-limited tree** → one-time limit dialog after `setRoom` (covers both selected-before-host and already-favorite) — `MainViewController.swift:1520-1537`; call site `MainViewController.m:2317-2320`. - **setRoom with already-grown room (endTime past, no chopper, different roomId)** → disables polling, nils `_room` if StartState, returns — `MainViewController.m:3062-3071` (polling disable guarded on `currentRoom.roomID == room.roomID`). - **Auto-poll 401/403/404 after creation** → disables polling, posts `CannotSeeRoom` (host kicked) — `TogetherDataManager.m:367-382`. - **Poll detail with no host participant** → disables polling, posts `CannotSeeRoom`, logs "no host", `callback(nil)` — `TogetherDataManager.m:306-325`. - **>6 total participants** → `presentAlertIfNeeded` (`fail_message_too_many_people`, cnt>5) exists but its only call site is commented out (`:247-250`); cap NOT enforced client-side — `TogetherFriendPickerViewController.m:200-218,247-250`. - **Dummy/test users** → filtered via `filterDummyUsersWith:`; footer shown/hidden by dummy count — `TogetherFriendPickerViewController.m:99-101`; `.swift:22-24`. ### Limitations - iOS create always sends `room_type:"chartered"`; no path creates shuttle_bus/birthday — `TogetherDataManager.m:76-82`; `TogetherRoomModel.m:77-88`. - Create does NOT use Moya `TogetherRoomEndpoint.create` (enum case exists with only `tree_type`+`target_duration`, never invoked) nor kit `postRoom`; uses raw `HttpRequestHelper postPath:'/api/v1/rooms'` — `TogetherRoomEndpoint.swift:5,21,59-63`; `TogetherDataManager.m:77`; kit `STForestKit/src/commonMain/kotlin/com/seekrtech/stforestkit/api/endpoints/legacy/RoomEndpoints.kt:45-57`. - `successRateThreshold` never sent on create (response-only); kit `CreateRoomRequest` omits it; client defaults 100 — kit `RoomEndpoints.kt:202-206`; `TogetherRoomModel.m:67-71`. - **Exactly THREE create params sent: `tree_type`, `target_duration`, `room_type:"chartered"`.** No tag/note. (Corrected: not two params.) — `TogetherDataManager.m:77-82`. - kit `postRoom`/`RoomEndpoints` support `roomType` (default chartered) but have no commonMain caller — `RoomEndpoints.kt:17,28-30`; consumer `DefaultRemoteRoomRepository.kt:15,20` uses only getRooms+chopRoom. - Host participant after create is synthesized locally — `TogetherDataManager.m:91-95`. - >5-friends cap dead/disabled — `TogetherFriendPickerViewController.m:247-250`. - Friend invitations fire-and-forget aggregated; failures not surfaced; room created regardless. (Note: room creation `TogetherDataManager.m:76-119` and invites `:398-440` are separate sequential server ops, fully decoupled.) ### Gates - Registered user required → not-registered placeholder; create → need-login dialog — `TogetherFriendPickerViewController.m:40-44,133-136`. - Tree must be unlocked (`isPlantUnlocked`) → else unlock/paywall/store — `PlantingManageViewController.swift:300-314`. - Host cannot *switch to* a usage-limited TinyTAN while hosting (only the active selection-attempt is blocked); host list unlocked-only. But an already-selected owned TinyTAN persists and creates the room — no usage-limited guard on create/update — `PlantingSetupViewModel.swift:92-95,130-136`; `TogetherDataManager.m:76-82,130-158`. - TinyTAN tree used → one-time limit dialog — `MainViewController.swift:1520-1537`. - First-time Together entry → walkthrough then notification prompt — `MainViewController.m:2516-2527`. - `cnstPreferenceTogetherInviationBlocked` affects inbound invite handling, not creation — `TogetherDataManager.swift:73-75`. --- ## 3. Sub-flow: Join-by-Code (manual room token entry) ### Summary User taps Join in Main View start footer → `TogetherEnterRoomCodeDialog`. Code is trimmed/uppercased and sent via Moya `PUT /rooms/participate {"token":CODE}` through `TogetherDataManager.joinRoom(token:)`. HTTP status → typed errors; success parses `TogetherRoomModel`, posts `DidJoinRoom`, dismisses dialog. Uses the app's own Moya stack (`TogetherRoomEndpoint` + `MoyaProvider+Promise`), **not** kit `RoomEndpoints.joinRoom`. ### Entry points - Join button — `StartFooterViewController.swift:232`. - `showJoinTogetherRoomAlertView` presents dialog — `MainViewController.m:2642`. - Text field auto-focuses after transition — `TogetherEnterRoomCodeDialog.swift:78-89`. - Submit — `TogetherEnterRoomCodeDialog.swift:104-114`. ```mermaid stateDiagram-v2 [*] --> DialogIdle : "tap Join" DialogIdle --> Dismissed : "tap Cancel" DialogIdle --> DialogIdle : "tap dim view (no dismiss) / submit empty (no-op)" DialogIdle --> Loading : "submit non-empty (trimmed + uppercased)" Loading --> SuccessDismissed : "200 or 409-rejoin + JSON castable" Loading --> InlineError : "errors — 200/409 uncastable · 400 invalidToken · 404 roomNotFound · 423 roomFull · 409 alreadyJoined · 483 treeTypeNotOwned · 498 roomInOtherServer · 403/network/maintenance/other" InlineError --> InlineError : "edit text (error re-hidden)" InlineError --> Loading : "submit again (non-empty)" SuccessDismissed --> [*] Dismissed --> [*] ``` ### Happy path 1. Tap Join → `showJoinTogetherRoomAlertView` — `StartFooterViewController.swift:232`. 2. Present dialog modally; `shouldDismissWhenTappingDimView=false` — `MainViewController.m:2643`; `TogetherEnterRoomCodeDialog.swift:18`. 3. `viewDidLoad`: title `enter_room_token_title`, placeholder `"XXXXXXX"+ForestServer.current.code`, error label hidden, Rx bindings — `TogetherEnterRoomCodeDialog.swift:16-70`. 4. Type code; each change re-hides error label — `:116-127`. 5. Submit → trim whitespace/newlines + uppercase; guard count>0 (empty = silent no-op) — `:131-132`. 6. Resign keyboard, start spinner, `joinRoom(token:)` — `:134-139`. 7. `PUT /rooms/participate {"token":code}` via `TogetherRoomEndpoint.join` — `TogetherDataManager.swift:331-333`; `TogetherRoomEndpoint.swift:9,29-30`. 8. Wrapper: no-network → `internetDisconnected`; token-refresh gate if token exists; retry ≤3 only on `accessTokenExpired` — `MoyaProvider+Promise.swift:8-104,143-151`. 9. Status switch: 200→parse; 400 invalidToken; 403 notAuthenticated; 404 roomNotFound; 409→rejoin if `shouldRejoinTogetherRoomAutomatically` else alreadyJoined; 423 roomFull; 483 treeTypeNotOwned; 498 roomInOtherServer; default unknown — `TogetherDataManager.swift:334-361`. 10. `mapToModel` casts JSON to dict → `TogetherRoomModel(json:)`; cast failure → `invalidServerResponse` — `:322-329`. 11. `didJoinRoom`: `isCreatedByCurrentUser=NO`, posts `DidJoinRoom`, enables polling — `:362-365`; `TogetherDataManager.m:121-128`. 12. MainVC observer clears pending state, sets room, refreshes selector to participant, stops loading — `MainViewController.m:3122,3317-3328`. 13. Dialog outer `.then` dismisses; `.always` stops spinner — `TogetherEnterRoomCodeDialog.swift:140-151`. ### States - Dialog idle/input — `TogetherEnterRoomCodeDialog.swift:24-32,78-89`. - Loading (spinner, keyboard dismissed) — `:134-136`. - Inline error (label shown, dialog open, clears on next edit) — `:122-124,144-148`. - Success/dismissed (→ participant mode) — `:142`; `MainViewController.m:3324-3327`. - Empty-submit no-op — `:132`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | Start footer | Tap Join | Dialog idle | Join button visible | | Dialog idle | Tap Cancel | Dismissed | — | | Dialog idle | Tap dim view | Dialog idle | `shouldDismissWhenTappingDimView=false` | | Dialog idle | Submit empty | Dialog idle | `code.count==0` early return | | Dialog idle | Submit non-empty | Loading | `code.count>0` | | Loading | 200/409-rejoin + parse ok | Success/dismissed | JSON castable | | Loading | 200/409 but uncastable | Inline error | `invalidServerResponse` | | Loading | 400/403/404/409-no-rejoin/423/483/498/other/network/maintenance | Inline error | — | | Inline error | Edit text | Inline error (re-hidden) | — | | Inline error | Submit again | Loading | `code.count>0` | ### Edge cases - **Empty/whitespace code** → silent no-op (no network/spinner/error) — `:131-132`. - **Whitespace/lowercase** → trimmed + uppercased; server gets normalized token — `:131`. - **No internet** → `internetDisconnected` before sending; `.catch` shows `error_message_internet_disconnected`; spinner stops — `MoyaProvider+Promise.swift:76-78`; `ForestAPIError.swift:20-21`. - **400** → invalidToken → `fail_message_invalid_room_code` — `TogetherDataManager.swift:339-340,292-293`. - **404** → roomNotFound → `fail_message_room_not_found` — `:343-344,294-295`. - **423** → roomFull → `fail_message_room_is_full` — `:352-353,296-297`. - **409** → if `shouldRejoinTogetherRoomAutomatically` (TOGETHER_AUTO_REJOIN, default true) silent rejoin; else alreadyJoined → `fail_message_already_joined` — `:345-351,298-299`; `CloudConfig.swift:147-148`. - **483** → treeTypeNotOwned → `dialog_package_11_cannot_join` — `:354-355,302-303`. - *[server]* 483 is emitted by `participate` when `tree_type.owners_only_in_rooms? && current_user doesn't own it` — a data-driven `TreeType` flag (not hardcoded to 89–95), applied regardless of `room_type` — `forest-server app/controllers/api/v1/rooms_controller.rb:186-188`. - **498** → roomInOtherServer → `enter_room_token_error_message_differentserver` — `:356-357,300-301`. - **403** → wrapper: if base==apiURL AND stored token invalid → `accessTokenExpired` (≤3 retries w/ refresh); else fulfills and join maps 403 → notAuthenticated (`error_message_not_authenticated`) — `MoyaProvider+Promise.swift:115-125`; `TogetherDataManager.swift:341-342`. - **401** → wrapper rejects `accessTokenExpired`; retried ≤3x; never reaches join's switch — `MoyaProvider+Promise.swift:113-114,143-151`. - **5xx + maintenance** → reject `.maintainance` (`server_error_maintenance_message`); else fulfilled, join default → `unknown(code)` (`fail_message_together_unknown (code)`) — `MoyaProvider+Promise.swift:126-131`; `TogetherDataManager.swift:358-359`; `ForestAPIError.swift:22-23,32-33`. - **Other status** → `unknown(code)` with appended numeric code — `TogetherDataManager.swift:358-359`; `ForestAPIError.swift:32-33`. - **200/409 body not a dict** → `invalidServerResponse` → `login_sign_up_unknown_error`; join does not complete — `:322-329`; `ForestAPIError.swift:28-29`. - **Tap dim view** → no dismiss — `:18`. - **Rapid double-tap submit** → no guard in dialog Promise path (no isIdle/_isJoining); each tap fires a new joinRoom Promise. The `MainViewController.joinRoom:` path (which HAS `_isJoining` guard) is NOT used by this dialog — `:104-114,130-152`; `MainViewController.m:2646-2650`. - **Success ordering** → inner `.then` runs `didJoinRoom` synchronously posting `DidJoinRoom` (MainVC sets room, stops loading) BEFORE outer `.then` dismisses; `.always` stops spinner again — `TogetherDataManager.swift:362-365`; `TogetherEnterRoomCodeDialog.swift:140-151`; `MainViewController.m:3317-3328`. - **`.always` stops spinner on every outcome** — `:149-151`. > NOTE: A claim that `[unowned self]` capture could crash on external dismissal was **REFUTED** (the dismiss reasoning was wrong: cancel button at `:98` is a second dismissal path) and is dropped. ### Limitations - Does NOT use stforestkit; kit `RoomEndpoints.joinRoom` (`api/endpoints/legacy/RoomEndpoints.kt:137-145`, `PUT api/v1/rooms/participate`, returns RoomDto) is a separate Android path — `TogetherDataManager.swift:321-366`; `TogetherRoomEndpoint.swift:1-89`. - No client-side format/length validation beyond trim+uppercase+non-empty; validity server-determined (400/404) — `:131-132`. - Auto-retry limited to 3 attempts, ONLY for `accessTokenExpired`. Generic failures/timeouts/5xx not retried — `MoyaProvider+Promise.swift:21,83-103,143-151`. - 409 governed by remote `TOGETHER_AUTO_REJOIN` (default true) — `:345-351`; `CloudConfig.swift:147-148`. - Placeholder cosmetic only — `:28`. - Dialog bypasses MainVC isIdle/_isJoining guards; no single-flight protection — `MainViewController.m:2646-2650` vs `TogetherEnterRoomCodeDialog.swift:130-152`. > NOTE: A claim that the wrapper's 403/401 interception "converts the user-facing message" for notAuthenticated was **REFUTED** (wrapper never produces notAuthenticated; the two outcomes are mutually exclusive) and is dropped. ### Gates - Network reachability — offline short-circuits with `internetDisconnected` — `MoyaProvider+Promise.swift:76-78`. - Stored auth token → token-refresh gate runs first; Bearer attached via `STEndpoint.headers` — `MoyaProvider+Promise.swift:54-65`; `STEndpoint.swift:33-74`. - `CloudConfig.isServerUnderMaintenance` → 5xx becomes maintenance error — `MoyaProvider+Promise.swift:126-130`. - `shouldRejoinTogetherRoomAutomatically` → 409 silent rejoin vs alreadyJoined — `TogetherDataManager.swift:345-351`. - Tree ownership (483) → treeTypeNotOwned — `:354-355`. - Server region (498) → roomInOtherServer — `:356-357`. --- ## 4. Sub-flow: Invitation Outbound (host invites friends) ### Summary Host invites friends to a chartered room from `TogetherFriendPickerViewController`. Friend list from `GET /api/v1/friends`; current user + existing participants filtered out; dummy users stripped. Host multi-selects (toggles per-row `isPending`), taps action → one `PUT /api/v1/rooms/{id}/invite {user_id}` per selected friend. If no room yet, action first creates the room then auto-invites. Push dispatch to invitees is entirely server-side. Uses native Obj-C `HttpRequestHelper`, NOT Moya `.invite` (defined but unused) nor kit `inviteUser`. ### Entry points - Start footer create button → `showTogetherFriendPicker` — `StartFooterViewController.swift:228` → `MainViewController.m:2638`. - `onCreateRoomSubject` — `MainViewController.swift:692-695`. - Header invite '+' (host owns room, not started) — `MainHeaderViewController.swift:337-342`. - Segue configures picker — `MainViewController.m:2312-2320`. - Picker `viewDidLoad` registers observers, loads friends — `TogetherFriendPickerViewController.m:24-45`. ```mermaid stateDiagram-v2 [*] --> NotRegisteredPlaceholder : "viewDidLoad (not registered)" [*] --> LoadingFriends : "viewDidLoad (registered)" NotRegisteredPlaceholder --> LoadingFriends : "login completes" LoadingFriends --> FriendList : "success, non-empty" LoadingFriends --> EmptyList : "success, 0 friends + footer hidden" LoadingFriends --> PopToMain : "403" LoadingFriends --> MaintenanceError : "5xx + maintenance" LoadingFriends --> GenericLoadFailure : "other failure" FriendList --> NeedLoginDialog : "action while unregistered" FriendList --> Sending : "action, currentRoom==nil (create then invite)" FriendList --> Sending : "action, currentRoom!=nil (invite-only)" Sending --> InviteDispatch : "DidCreateRoom (create branch)" InviteDispatch --> Sending Sending --> UnwindToMain : "DidSendInvitations (all settle, failures dropped)" Sending --> StuckSpinner : "invite called but _room nil (DidSendInvitations never posts)" UnwindToMain --> [*] ``` ### Happy path 1. Segue sets `currentRoom`, treeType, duration, completionBlock — `MainViewController.m:2312-2320`. 2. `viewDidLoad`: enable multi-select, register DidCreateRoom/DidSendInvitations observers; if registered load friends else placeholder — `TogetherFriendPickerViewController.m:24-45`. 3. `viewWillAppear`: title/button = create vs invite depending on currentRoom — `:47-54`. 4. `loadFriends`: spinner + `GET /api/v1/friends` — `:66-71`. 5. Server returns friend array → `TogetherParticipantModel.initWithJSON` — `TogetherParticipantModel.m:13-46`. 6. Filter current user + existing participants; strip dummies — `:74-99`; `.swift:22-24`. 7. Migration-note footer from dummy count; empty-list label; reload — `:101-107`. 8. Tap rows toggle `isPending`; deselect toggles back — `:237-261`. 9. Tap action button — `:132`. 10. `onActionButtonClicked`: not registered → need-login + return; `currentRoom!=nil` → invite-only; else create — `:132-157`. 11. CREATE: `POST /api/v1/rooms {tree_type, target_duration, room_type:"chartered"}` — `TogetherDataManager.m:76-82`. 12. 201 → build room, `isCreatedByCurrentUser`, self host participant, post `DidCreateRoom`, enable polling — `:84-100`. 13. `didCreateRoom:` collects pending, calls `invite:` — `TogetherFriendPickerViewController.m:161-172`. 14. `invite:` early-return if room nil; empty → immediate `DidSendInvitations`; else `PUT /invite {user_id}` per friend, track status, post `DidSendInvitations` when all settle — `TogetherDataManager.m:398-440`. 15. Per invite: 200→YES else NO; failure→NO. Server dispatches APNs push (server-side) — `:427-438`. 16. `didInviteFriends:` appends invited, fires completionBlock, unwinds to Main, stops HUD — `TogetherFriendPickerViewController.m:174-198`. 17. completionBlock: set room + `showTinyTANTogetherLimitDialogIfNeeded` — `MainViewController.m:2317-2320`. 18. INVITEE side: APNs `invite` payload → `handlePushNotification` builds payload, stores `pendingInvitation`, schedules local notification (background) or posts `DidReceiveInvitation` (foreground) — `TogetherDataManager.swift:69-138`. ### States Loading friends; Not-registered placeholder; Friend list multi-select; Migration-note footer; Empty list; Maintenance error; Generic load failure; Need-login dialog; Sending invitations; Invitee local notification. Refs: `TogetherFriendPickerViewController.m:40-126,154,197`; `.swift:26-58,108-119`; `TogetherDataManager.swift:102-126`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | viewDidLoad | not registered | Not-registered placeholder | `isRegisteredUser false` | | viewDidLoad | registered | Loading friends | — | | Loading | success, non-empty | Friend list | — | | Loading | success, 0 friends + footer hidden | Empty list | `friends.count==0 && footerHidden` | | Loading | 403 | popToMainView | — | | Loading | 5xx + maintenance | Maintenance error | `isServerUnderMaintenance` | | Loading | other failure | Generic load failure | — | | Friend list | action while unregistered | Need-login dialog | `isRegisteredUser==NO` | | Friend list | action, currentRoom==nil | Sending (create then invite) | — | | Friend list | action, currentRoom!=nil | Sending (invite-only) | — | | Sending | DidCreateRoom | invite: dispatch | create branch | | Sending | DidSendInvitations | unwind to Main | — | ### Edge cases - **No friends selected, invite-only** → empty `invitedFriends`; `invite:` immediately posts `DidSendInvitations`; unwinds with currentRoom unchanged (no-op) — `TogetherDataManager.m:401-405`; `TogetherFriendPickerViewController.m:174-198`. - **No friends selected, create branch** → room still created; `didCreateRoom` collects 0 pending; immediate `DidSendInvitations`; lands in empty room — `:153-156,161-172`. - **`invite:` called but `_room` nil** → silent early return; `DidSendInvitations` never posts → picker never unwinds, HUD never stops (stuck spinner). Reachable only if room went nil between create and invite — `TogetherDataManager.m:398-399`. - **Partial invite failure** → each failure NO; `doneIfNeeded` fires once all settle; `DidSendInvitations` posts regardless (userInfo:nil); failed invitees still optimistically appended — `TogetherDataManager.m:424-439`; `TogetherFriendPickerViewController.m:174-190`. - **Invite PUT non-200 (not network failure)** → status NO, no error notification, no user message — `TogetherDataManager.m:427-433`. - **Friend already a participant** → excluded at loadFriends AND re-excluded via `existingParticipantIDs` in onActionButtonClicked/didInviteFriends — `TogetherFriendPickerViewController.m:82-89,140-150,175-185`. - **Dummy/cross-server users** → `filterDummyUsersWith` removes `is_dummy_user==true`; count drives footer (singular/plural); cannot be invited — `.swift:22-24,26-58`; `TogetherParticipantModel.m:28-30`. - **loadFriends 403** → `popToMainView` — `:110-111,104-106`. - **Create POST not 201/non-dict** → posts `TogetherDataManagerErrorTypeUnknown`; DidCreateRoom never fires so invite never happens. The global SKLoadingView **is** stopped by MainVC's error observer (`didRecieveTogetherError:` → `[SKLoadingView stopAnimating]` at `MainViewController.m:3208-3209`). (Corrected: not a stuck spinner; FriendPicker registers no error observer, but the global error observer fires.) — `TogetherDataManager.m:101-108`; HUD start at `TogetherFriendPickerViewController.m:154`. - **Create failure 403/5xx-maintenance/other** → NotAuthenticated / MaintenanceInProgress / Unknown — `TogetherDataManager.m:110-118`. - **>5 selection cap** → `presentAlertIfNeeded` dead code (call sites commented out); no client cap — `TogetherFriendPickerViewController.m:200-218,247-250`. - **didDeselect also toggles isPending** → relies on `allowsMultipleSelection=YES` — `:32,237-261`. - **Invite-after-room-start** → NOT supported via header '+': invite cell only counted/rendered/tappable when `isCreatedByCurrentUser && startTime==nil` — `MainHeaderViewController.swift:309-313,318-325,337-343`. - **Re-invite (room exists, not started)** → header '+' → picker invite-only (`currentRoom=_room`), excludes existing, button `invite_friends_button_title` — `MainHeaderViewController.swift:135-137`; `MainViewController.m:2312-2320`. - **Invitee push while pendingInvitation set** → returns false (one at a time) — `TogetherDataManager.swift:90-93`. - **Invitee push while invitations blocked** → returns false immediately (`cnstPreferenceTogetherInviationBlocked`, Settings toggle inverted at `SettingsViewModel.swift:631`) — `TogetherDataManager.swift:73-75`. - **Invitee push missing fields / unknown room_type** → payload init returns nil; handler returns false — `TogetherDataManager.swift:18-49,95-97`. - **Invitee taps notification** → builds invitation; different roomID → conflict bail; else set pending + post `DidReceiveInvitation` — `TogetherDataManager.swift:234-256`. - **Login completes in picker** → `hidePlaceHolderView` then `loadFriends` — `.swift:122-127`. - **Double-create → re-invite** → `createRoom` has no double-call guard (`TogetherDataManager.m:76`); a double create (e.g. rapid tap) posts `DidCreateRoom` twice, and `didCreateRoom:` (`:161-172`) is itself **unguarded**, so `invite:` re-fires. Pending flags determine WHAT gets re-invited but do NOT gate/suppress the re-trigger. (A single 201 posts the notification only once.) — `TogetherFriendPickerViewController.m:35-36,41,161-172`. ### Limitations - iOS invite uses native `HttpRequestHelper`, NOT Moya `.invite` (defined, unused) nor kit `inviteUser` — `TogetherDataManager.m:424-439`; `TogetherRoomEndpoint.swift:10,31-32,81-84`; kit `RoomEndpoints.kt:154-162`. - Invite body carries only `user_id`; tree_type/target_duration/room_type set at create — `TogetherDataManager.m:426`; kit `RoomEndpoints.kt:160,220-223` (`InviteUserRequest` has only `user_id`). - No client max on invitees; >5 guard dead. Server: start 423 RoomTooVacant (too few), join 423 RoomFull — `TogetherFriendPickerViewController.m:200-218,247-250`; `TogetherDataManager.m:525-526`; `TogetherDataManager.swift:352-353`. - Only chartered created/invited; room_type hardcoded at create+update — `TogetherDataManager.m:81,138`. - Invites fired in parallel, no ordering/cancellation/timeout; `doneIfNeeded` waits for every request → one hung request blocks unwind/spinner indefinitely — `TogetherDataManager.m:413-439`. - Failed invites shown locally as invited (optimistic); UI can diverge from server. **Correction:** the next `fetchRoom` does NOT reconcile these away — the merge loop retains ALL pending participants unconditionally (`TogetherDataManager.m:277-278`), only dropping non-pending absent ones. Stale pendings persist — `TogetherFriendPickerViewController.m:174-198`; `TogetherDataManager.m:274-286`. - Friend list = `GET /api/v1/friends` (social graph, accepted/mutual friends), independent of PlantTogether — `TogetherFriendPickerViewController.m:71`. - Push delivery to invitees entirely server-side — `TogetherDataManager.m:424-439`. - Invite '+' UI only for host before start — `MainHeaderViewController.swift:309-343`. ### Gates - Not registered → placeholder on load; action → need-login — `:40-44,133-136`. - currentRoom nil vs non-nil → create-then-invite vs invite-only — `:51-52,138-156`. - Already a participant → excluded — `:82-89,140-150,175-185`. - `is_dummy_user==true` → filtered out, footer-only — `.swift:22-24`; `TogetherParticipantModel.m:28-30`. - Host owns room AND startTime==nil → invite '+' shown/tappable — `MainHeaderViewController.swift:309,318,338`. - `cnstPreferenceTogetherInviationBlocked` (invitee) → inbound invite dropped — `TogetherDataManager.swift:73-75`. - pendingInvitation set (invitee) → new push ignored — `:90-93`. - TinyTAN usage limit (host post-create) → limit dialog — `MainViewController.m:2317-2320`. --- ## 5. Sub-flow: Invitation Inbound (receive → accept/decline) ### Summary APNs push `custom.type=="invite"` routed by AppDelegate to `TogetherDataManager.handlePushNotification`, building a `TogetherInvitationModel` stored as `pendingInvitation`. Either posts in-app `DidReceiveInvitation` (MainVC → dialog) or schedules a local banner (tap → `handleNotificationResponse`). Dialog is standard `TogetherInvitationViewController` or, for TinyTAN usage-limited types, `TinyTANTogetherInvitationCTAViewController`. Accept → `joinRoom(token:)` (`PUT /rooms/participate` via Moya). Decline → `declineInvitation(roomID:)` (`PUT /api/v1/rooms/{id}/reject` via legacy `HttpRequestHelper`). No client-side expiry; staleness surfaces only as join HTTP errors. Entirely iOS-native; no kit. ### Entry points - APNs push `aps.custom.type=='invite'` — `AppDelegate.m:469` → `:477` → `TogetherDataManager.swift:69`. - Tap local banner — `AppDelegate.m:512` → `:522` → `TogetherDataManager.swift:220`. - MainVC observes `DidReceiveInvitation` — `MainViewController.m:3128`. - MainVC picks up stored pendingInvitation on appear/become-active — `MainViewController.m:288,524`. ```mermaid stateDiagram-v2 [*] --> NoInvitation NoInvitation --> NoInvitation : "push blocked (busy / malformed / in-room)" NoInvitation --> InvitationDialog : "push — foreground & idle" NoInvitation --> PendingBanner : "push — background" PendingBanner --> InvitationDialog : "tap banner (idle)" PendingBanner --> PendingBanner : "tap banner (other room pending)" InvitationDialog --> TinyTANCTA : "unowned, limited, purchasable" InvitationDialog --> CannotJoinError : "unowned, limited, not purchasable" InvitationDialog --> Joining : "tap Accept" InvitationDialog --> NoInvitation : "tap Decline (reject, clear)" Joining --> InRoom : "200 / allowed-409" Joining --> JoinError : "400/403/404/409/423/483/498" TinyTANCTA --> TinyTANViewController : "Accept (declines, no join)" TinyTANCTA --> NoInvitation : "Reject (decline)" InRoom --> [*] ``` ### Happy path 1. APNs delivered; AppDelegate saves+sends receipt — `AppDelegate.m:470-473`. 2. Gate `getMainAppIsIdle()`; if NOT idle returns immediately WITHOUT calling completionHandler — `AppDelegate.m:475`. 3. Route to `handlePushNotification` — `AppDelegate.m:477`. 4. Bail if `TOGETHER_INVIATION_BLOCKED` — `TogetherDataManager.swift:73`. 5. Parse `aps.custom`; require type, require `mainController as? UIViewController`; switch on `invite` — `:77-90`. 6. If `pendingInvitation` set, return false — `:91-93`. 7. Build `TogetherInvitationNotificationPayload` (duration, tree_type→Species, name, room_token, room_id>0, valid room_type) — `:18-49`. 8. `convertToInvitationModel()` → `pendingInvitation` — `:99-101`. 9. Branch: top VC not main / main has presented VC / not active → schedule local notification; else post `DidReceiveInvitation` — `:102-135`. 10. `fetchCompletionHandler(.newData)` return true — `:137-138`. 11. (Banner) tap → `handleNotificationResponse`: parse type, rebuild payload; existing pending with different roomID → conflict bail; else set pending + post `DidReceiveInvitation` — `:234-256`. 12. MainVC `didReceiveInvitation:`: if `_room!=nil` or `_pendingInvitation!=nil` ignore; else store, `disablePopUp`, `showInvitationViewIfNeeded` — `MainViewController.m:3278-3289`. 13. `showInvitationViewIfNeeded`: requires pending + `StartState`; dismiss non-invitation presented VC; popToRoot + 0.5s delay → `showTogetherInvitationDialog` — `MainViewController.m:3231-3276`. 14. `showTogetherInvitationDialog`: guard species+product; defer clears pending + `removeAllDeliveredNotifications`. If unowned + usageLimited: canBePurchased → TinyTAN CTA else cannot-join error; otherwise present `TogetherInvitationViewController` — `MainViewController.swift:1541-1584`. - ⛔ **TinyTAN retired (see §9):** `canBePurchased(TinyTAN)` is now permanently false, so the `TinyTAN CTA` branch (`TinyTANTogetherInvitationCTAViewController`) is **DEAD**; an unowned + usage-limited TinyTAN invite now **always** lands on the cannot-join error. Owned TinyTAN invites are unaffected. 15. Tap Accept → spinner, `joinRoom(token:)`, dismiss, fire onActionPerformed once — `TogetherInvitationViewController.m:37-48`. 16. join 200/allowed-409 → `didJoinRoom` (`isCreatedByCurrentUser=NO`, post DidJoinRoom, polling) — `TogetherDataManager.m:121-128`. 17. MainVC `didJoinRoom:`: clear pending, set room, refresh selector, stop loading — `MainViewController.m:3317-3328`. 18. Tap Decline → `declineInvitation(roomID)`, dismiss, fire onActionPerformed once — `TogetherInvitationViewController.m:50-60`. 19. `declineInvitation`: `PUT /api/v1/rooms/{id}/reject`; on BOTH success+failure clears `_pendingInvitation` and `_room` (fire-and-forget) — `TogetherDataManager.m:442-456`. ### States No invitation; Pending (background banner); Invitation dialog (standard); Invitation dialog (TinyTAN CTA); Cannot-join error; Joining; In room; Join error. Refs: `TogetherDataManager.swift:104-126,309-319`; `TogetherInvitationView.m:33-65`; `TinyTANTogetherInvitationCTAViewController.swift:20`; `MainViewController.swift:1568-1573`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | No invitation | push while not idle | No invitation | `getMainAppIsIdle()==false` (completionHandler not called) | | No invitation | push, invitations blocked | No invitation | `TOGETHER_INVIATION_BLOCKED` | | No invitation | push, active + main on top + nothing presented | Invitation dialog | `active && top==main && presented==nil && pending==nil` | | No invitation | push, background / main not foremost | Pending (banner) | NOT (active && main && nothing presented) | | Pending (banner) | tap banner | Invitation dialog | idle AND (no pending OR same roomID) | | Pending (banner) | tap banner, different room pending | Pending (banner) | conflict → false | | Invitation dialog | Tap Accept | Joining | — | | Joining | 200 / allowed-409 | In room | `200 OR (409 && shouldRejoin)` | | Joining | 400/403/404/409-no/423/483/498/other | Join error | — | | Invitation dialog | Tap Decline | No invitation | fire-and-forget reject; pending+room cleared regardless | | TinyTAN CTA | Tap Accept | TinyTANViewController | rejects room then opens purchase (does NOT join) | | TinyTAN CTA | Tap Reject | No invitation | declineInvitation | ### Edge cases - **App not idle on push** → AppDelegate returns before Together routing AND without calling completionHandler. **Correction:** the notification receipt IS saved + sent first (`AppDelegate.m:472-473`); only in-app Together routing/UI is skipped — `AppDelegate.m:469-498` (gate `:475`, fallback completionHandler `:496`). `getMainAppIsIdle` → `mainAppStateManager.isIdle`. - **Invite while already in a room** → `didReceiveInvitation` returns if `_room!=nil`; `viewWillAppear` clears pending when in a room → no dialog — `MainViewController.m:3279,296-301`. - **Second invite same room while pending** → `handlePushNotification` returns false; falls through to other handlers — `TogetherDataManager.swift:91-93`; `AppDelegate.m:477-493`. - **Banner tap for different room** → conflict detected, `completionHandler()` + false; old pending stays — `TogetherDataManager.swift:240-243`. - **Malformed payload** → init returns nil; handlePushNotification false; handleNotificationResponse `completionHandler()` then false — `:18-49,95-97,235-238`. - **Invitations disabled** → early false at guard; handleNotificationResponse does NOT check flag but a banner can only exist if a prior handlePushNotification scheduled it — `:73`; `SettingsViewModel.swift:631`. - **mainController not a UIViewController** → false at guard, drop — `:85-87`. - **Decline network fails** → still clears `_pendingInvitation` + `_room`; no retry/error; dialog already dismissed — `TogetherDataManager.m:451-455`. - **Accept on stale/expired/full/not-found** → no client expiry timer; only join HTTP errors (400/404/423/409) — `TogetherDataManager.swift:336-360`. - **Accept 409 (already participant)** → if `shouldRejoinTogetherRoomAutomatically` silent rejoin; else alreadyJoined — `:345-351`. - **Accept 483 (TinyTAN not owned, via standard dialog)** → treeTypeNotOwned → `postErrorNotification(.typeTreeTypeNotOwned)`; `showTogetherJoinRoomTypeTreeTypeNotOwnedErrorDialog` offers TinyTAN upsell or cannot-join — `:354-355`; `MainViewController.swift:1594-1618`. - *[server]* **Invite endpoint does NOT check tree ownership** — `forest-server rooms_controller.rb:108-139`. A host can invite a user who doesn't own the room's tree; that user only hits 483 when they **accept** (accept routes through `participate`, `:186-188`). Poor-UX gap: the block surfaces at accept-time, not invite-time. - **TinyTAN CTA Accept** → `showTinyTANEventPage` declines invitation first, then opens `TinyTANViewController`; does NOT join the room — `TinyTANTogetherInvitationCTAViewController.swift:244-254`. - **Foreground with presented modal** → treated as background-style: schedules local banner — `TogetherDataManager.swift:102-126`. - **Stacked navigation** → popToRoot + 0.5s delay; non-invitation presented VC dismissed first — `MainViewController.m:3237-3276`. - **species/product nil at dialog time** → guard returns BEFORE defer registration, so `pendingInvitation` is NOT cleared and notifications not removed — `MainViewController.swift:1542-1555`. - **Delivered-notification cleanup** → `removeAllDeliveredNotifications()` in defer clears ALL delivered notifications (fires on every exit path) — `MainViewController.swift:1553-1554`. - **onActionPerformed double-fire protection** → Accept+Decline both null out `_onActionPerformed` after calling — `TogetherInvitationViewController.m:44-47,56-59`. - **birthday_2019_random** → banner body uses `birthday_2019_cake_box_title`; standard dialog shows `birthday_2019_cake_box` image — `TogetherDataManager.swift:111-113`; `TogetherInvitationView.m:40-44`. ### Limitations - No invitation expiry/TTL; model carries no expiry; staleness only at accept-time via join errors — `TogetherInvitationModel.h:1-15`; `TogetherDataManager.swift:336-360`. - One pending invitation at a time; later invites for other rooms silently dropped (push) or rejected as conflict (banner tap), no user notice — `:91-93,240-243`. - Receipt fully gated on `getMainAppIsIdle()`; during active focus both handlers early-return; background completionHandler leaked — `AppDelegate.m:475,517`. - Decline fire-and-forget; no UI/retry; local state cleared regardless — `TogetherDataManager.m:442-456`. - Networking iOS-native (join Moya, reject legacy); no kit — `TogetherDataManager.swift:331-333`; `TogetherDataManager.m:444`. - `TogetherRoomEndpoint.declineInvitation` is dead code; path string `"/rooms/\(roomID)\reject"` uses literal `\r` carriage-return escape (malformed); real reject uses Obj-C `"/api/v1/rooms/%@/reject"` — `TogetherRoomEndpoint.swift:34`; `TogetherDataManager.m:442-444`. - Standard accept leaves spinner spinning if joinRoom fails; only stopped on `didJoinRoom` or via the error observer (`MainViewController.m:3208-3210`); all join-failure error types route through the error observer so the spinner is stopped in practice — `TogetherInvitationViewController.m:40-41`; `MainViewController.m:3326`. - Showing dialog requires `_state==StartState`; in any other state the pending invitation waits until back to start + active — `MainViewController.m:3232`. ### Gates - Main app not idle → all push/banner handling early-returns; invitation neither stored nor displayed (receipt analytics still recorded) — `AppDelegate.m:475,517`. - `TOGETHER_INVIATION_BLOCKED` → returns false at entry — `TogetherDataManager.swift:73`. - TinyTAN usage-limited + not unlocked → CTA upsell (if purchasable) or cannot-join; pending consumed either way — `MainViewController.swift:1558-1574`. - Server 483 → treeTypeNotOwned → upsell/cannot-join — `:354-355`; `MainViewController.swift:1594-1618`. - Server 403 → notAuthenticated → mapped to `.typeUnknown` by objc catch (only JoinRoomError gets specific mapping) — `:341-342,311-317`. - `_room!=nil` or `_pendingInvitation` set → ignore new invitation — `MainViewController.m:3279-3280`. --- ## 6. Sub-flow: Walkthrough Onboarding (`TogetherWalkthroughViewController`) ### Summary A 4-page swipe tutorial presented at most once (persisted in NSUserDefaults; not truly "per device" — can reset on reinstall/restore), the first time the user switches into Together mode while not in a room. Gated by a single NSUserDefaults bool `USER_PREFERENCE_TOGETHER_WALKTHROUGH_PLAYED`. **No kit/server involvement.** The flag is set YES at **presentation** time (not dismissal). Dismissable only via a close button revealed after reaching page 4; dim-tap dismiss disabled. On dismiss → notification-permission prompt (if logged in and still in Together mode). ### Entry points - `didChangeSelectedMode` (Together mode) — `MainViewController.m:2513`. - `updateUIBasedOnSelectedMode:` (StartState, `_room==nil`) → `didChangeSelectedMode` — `:2497-2511`. - `switchState:StartState` (`_room==nil`) — `:2040-2042`. ```mermaid stateDiagram-v2 [*] --> EnterTogether EnterTogether --> NotShown : "flag==YES (already played) -> ask notification" EnterTogether --> NotShown : "_room != nil (returns early)" EnterTogether --> Page1 : "flag==NO && isInTogetherMode (persist flag YES at presentation)" Page1 --> Page2 : "swipe" Page2 --> Page3 : "swipe" Page3 --> Page4 : "swipe" Page4 --> Page3 : "swipe back (close stays visible)" Page4 --> Dismissed : "close tap (close only visible on page index 3)" Dismissed --> NotificationAsk : "viewDidDisappear (isInTogetherMode && isLogin)" Dismissed --> NotShown : "viewDidDisappear (not login / not Together)" NotificationAsk --> [*] NotShown --> [*] ``` ### Happy path 1. Select Together mode — `:2516`. 2. Check `cnstPreferenceTogetherWalkthroughPlayed` — `:2517`. 3. Flag NO → instantiate `[TogetherWalkthroughViewController new]` — `:2518`. 4. Present (OverFullScreen, no animation) — `:2519`; `SKPresentableViewController.m:45-52`. 5. Set onDismiss → `askForNotificationPermissionsIfNeeded` — `:2520-2522`. 6. **Persist played flag YES synchronously, before VC appears/dismisses** — `:2523`. 7. `viewDidLoad`: load xib, disable dim-tap, hide close button, 4 page views, Lottie — `TogetherWalkthroughViewController.m:19-49`. 8. Pages populate `together_tutorial_title_N` / `_description_N` (N=1..4) — `TogetherWalkthroughPageView.m:24-33`. 9. `viewDidAppear` plays page-0 Lottie — `:57-61`; `.swift:13-24`. 10. Swipe → `scrollViewDidEndDecelerating` computes page, plays segment, updates page control — `:73-83`. 11. Reaching page index 3 → reveal close button — `:79-81`. 12. Tap close → `dismissal()` → animate → `onAlertViewDismiss` → dismiss modal — `TYAlertViewContainerView.m:194-213`; `SKPresentableViewController.m:108-110,121-131`. 13. `viewDidDisappear` fires `onDismiss()` then nils — `:63-71`. 14. `onDismiss` → `askForNotificationPermissionsIfNeeded` (if Together + login) — `MainViewController.m:2488-2495`. ### States Not shown (already played); Page 1/2/3/4; Dismissed. Lottie segments: p1 0→1, p2 2→19, p3 20→39, p4 40→56. Refs: `TogetherWalkthroughViewController.m:60,79-81`; `.swift:28-35,43-50`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | Enter Together | flag==NO | Page 1 | `USER_PREFERENCE_TOGETHER_WALKTHROUGH_PLAYED == false && isInTogetherMode` | | Enter Together | flag==YES | Not shown | flag true | | Page 1→2→3→4 | swipe | next page | — | | any page | swipe back | earlier page (reverse Lottie) | `newPage < currentPage` | | Page 4 | close tap | Dismissed | close only visible after page index 3 | | Dismissed | viewDidDisappear | Notification ask (or no-op) | `isInTogetherMode && isLogin` | ### Edge cases - **Backgrounds/kills on pages 1-3** → flag already YES at presentation; never shown again even though never finished — `MainViewController.m:2523`. - **Tap dim background** → no-op (`setShouldDismissWhenTappingDimView:NO`). **Correction:** dim-tap is a no-op on EVERY page (never re-enabled), not just before page 4; page-4 dismiss is via the close button — `TogetherWalkthroughViewController.m:25`; `TYAlertViewContainerView.m:184`; close at `:194`. - **Pages 1-3 no close button** → no way to dismiss; close hidden until `page==3` (0-indexed 4th page) — `:28,79-81`; `TYAlertViewContainerView.m:86-87`. - **Page index via int truncation of `contentOffset.x / bounds.width`** → only fires on deceleration end, not partial/programmatic — `:73-83`. - **playAnimationForPage index outside 0-3** → `default: fatalError()` (crash); reachable only if scroll reports >3; unreachable with 4-page xib — `.swift:37,52`; `.m:75-76`. - **animationView not an AnimationView** → guard-returns silently; pages still function — `.swift:14`. - **Page 4 then swipe back** → close button stays visible (one-way; no re-hide); reverse Lottie plays — `.m:79-81`; `.swift:17-23`. - **On dismiss, not logged in** → no permission prompt — `MainViewController.m:2491`. - **On dismiss, not in Together mode** → no prompt — `:2490`. - **`_room != nil`** → `updateUIBasedOnSelectedMode` returns early; walkthrough never reached — `:2500-2502`. - **Return to foreground** → `updateUIBasedOnSelectedMode:` runs as observer but **the notification `object` is reinterpreted as the `MainViewState` enum arg, so `state != StartState` and `didChangeSelectedMode` is NOT called on foreground at all.** Walkthrough does not re-show because the state guard fails (not because the flag blocks it). Real StartState only via direct call at `:2042`. (Corrected mechanism.) — `:2497-2511`; enum `MainViewController.h:19-23`. ### Limitations - Shown at most once per device install; single bool, no reset path — `:2517,2523`. - Flag persisted at PRESENTATION time, not dismissal — `:2523`. - Local-only (NSUserDefaults), not synced; reinstall/new device re-shows. No kit participation (kit `onboard/` module is the unrelated app-onboarding walkthrough) — `:2517`. - Fixed 4 pages hardcoded by xib (4 page views) + Lottie frame switch (cases 0-3); adding pages needs both or fatalError — `.swift:26-54`; `TogetherWalkthroughView.xib`. - Generic Together content; does not distinguish room types — `TogetherWalkthroughPageView.m:24-33`. - `cnstPreferenceTogetherTutorialShown` (`TOGETHER_TUTORIAL_SHOWN`) defined but never read/written — dead/legacy — `PreferenceConstants.h:55`. - NSUserDefaults write not explicitly synchronized — `:2523`. ### Gates - `_modeSelector.isInTogetherMode` true — `:2516`. - `_room` nil — `:2500-2502`. - `USER_PREFERENCE_TOGETHER_WALKTHROUGH_PLAYED == NO` (else skip + ask notification) — `:2517,2524`. - Post-dismiss prompt: `isLogin AND isInTogetherMode` — `:2490-2493`. --- ## 7. Sub-flow: In-Session Live State (header live participants) ### Summary During an active Together session the room is owned by the Obj-C `TogetherDataManager`, which polls `GET /api/v1/rooms/{id}` on a self-rescheduling NSTimer and reacts to APNs `update`/`chop`/`reject` pushes. Each fetch merges the server participant list, posts `DidUpdateRoom`, and `MainViewController.setRoom` drives the live UI: avatar strip (`MainHeaderViewController`) and shuttle_bus success-rate message. Mid-session failures detected via `failed_at`/`chopper_user_id`. The kit's room use cases are a separate post-session sync pipeline; the live loop uses native `HttpRequestHelper`. `PlantingManageViewController` is pre-session setup, not in-session. ### Entry points - Host starts (`PUT /rooms/{id}/start`) — `TogetherDataManager.m:483`; `MainViewController.m:3336`. - Polling timer fires — `TogetherDataManager.m:622,642`. - APNs `update`/`chop`/`reject` — `TogetherDataManager.swift:139,184,157`. - App relaunch with ongoing plant → `restoreRoom` — `MainViewController.m:872`; `TogetherDataManager.m:49`. - Host taps participant avatar — `TogetherHeaderFriendView.m:61`. - `DidUpdateRoom` → `setRoom` → header update — `MainViewController.m:3300,3046`; `MainHeaderViewController.swift:104,143`. ```mermaid stateDiagram-v2 [*] --> WaitingToStart WaitingToStart --> WaitingToStart : "re-enter — poll merges new confirmed (isPending cleared) / host kick PUT /kick 200/410 (host && !participantsFinalized)" WaitingToStart --> InSession : "host PUT /start 200 (>=2 participants, else 423 RoomTooVacant)" InSession --> InSession : "poll merge / DidUpdateRoom (avatar strip + bus message)" InSession --> LocalPlantChopped : "poll endTime + chopper while GrowingState" InSession --> NormalFinish : "poll endTime past, chopper==0 (setRoom early-returns)" InSession --> FinishedFail : "local give-up/leave (needsChopper if chartered or bus not ended)" WaitingToStart --> RoomGone : "poll 401/403/404 or no-host (CannotSeeRoom)" InSession --> RoomGone : "poll 401/403/404 or no-host (CannotSeeRoom) / host leave PUT /leave 200 (room destroyed)" FinishedFail --> ManagerReset : "Finished handler && _room!=nil -> reset()" LocalPlantChopped --> ManagerReset RoomGone --> ManagerReset ManagerReset --> [*] NormalFinish --> [*] ``` ### Happy path 1. Host starts; start endpoint returns room JSON with start_time; pending stripped — `TogetherDataManager.m:486-504`. 2. `didStartPlanting`/`setRoom`: cancel disabled, duration/treeType locked, plant animation — `MainViewController.m:3336,3086-3095`. 3. `setAutoPollingEnabled:YES`; once started poll uses `roomInfoOnly=YES` — `TogetherDataManager.m:627,150`. 4. Timer → `fetchRoom(roomInfoOnly, manual:NO)` → `GET /api/v1/rooms/{id}` — `:189`. 5. Server returns room JSON — `TogetherRoomModel.m:7`. 6. roomInfoOnly: if `updateAt` newer → needsMoreInfo, copy scalars, immediate full detail fetch — `TogetherDataManager.m:222-246`. 7. Detail merge: pending→confirmed, update name/avatar/failAt, add new, remove absent (unless pending), sort host>participant>pending, recompute isChopper — `:254-345`. 8. Post `DidUpdateRoom` (if manual||polling); reschedule timer — `:347-365`. 9. `setRoom`: endTime+chopper & GrowingState → `onTogetherPlantBeingChopped` + stop polling; elif startTime & GrowingState → bus status; else update header — `MainViewController.m:3080-3103`. 10. `header.switchStateWithRoom` → `updateParticipants` (serial queue, duplicate room, diff, animate removals, batch reload) — `MainHeaderViewController.swift:104,143-283`. 11. Cell `setParticipant`: avatar (placeholder `icon_circle`), host star, chopper mark if isChopper, dark mask + jumpingDots if pending — `TogetherHeaderFriendView.m:107-122`. 12. shuttle_bus: live participant/failed counts + max-failures — `TogetherRoom+ShuttleBusMessages.swift:30`; `TogetherRoomModel.m:205`. 13. Local give-up → `needsChopper=@YES` (chartered, or bus not ended) → sync — `MainViewController.m:1052,1075-1089`. 14. On sync → `chopPlant` → `PUT /rooms/{id}/chop?end_time=...` — `DataSyncManager.swift:1027-1057`; `TogetherDataManager.m:538`. ### States WaitingToStart (host) / (guest); InSession (growing); Participant pending (masked); Participant failed (chopper); LocalPlantChopped; RoomGone/kicked. Refs: `MainHeaderViewController.swift:108-118,309-324`; `MainViewController.m:1136-1176,3181-3185`; `TogetherHeaderFriendView.m:119-121`; `TogetherParticipantModel.m:57-60`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | WaitingToStart(host) | PUT /start 200 | InSession | ≥2 participants (else 423 RoomTooVacant) | | WaitingToStart | poll merges new confirmed | WaitingToStart | was pending → isPending cleared | | WaitingToStart(host) | kick → PUT /kick 200/410 | WaitingToStart | `isCurrentUserHost && !participantsFinalized` | | InSession | poll endTime+chopper | LocalPlantChopped | `state==GrowingState` | | InSession | poll endTime past, chopper==0 | normal finish | setRoom early-returns | | InSession | local give-up/leave | FinishedState (fail) | needsChopper if chartered or bus-not-ended | | any in-room | poll 401/403/404 or no-host | RoomGone | CannotSeeRoom → reset() | | any in-room | host leave (PUT /leave 200) | RoomGone | room destroyed | | FinishedState | state Finished + _room!=nil | manager reset | reset() in Finished handler | ### Edge cases - **Poll network failure (non-4xx)** → if `!manual && autoPolling` reschedule timer, retry; `callback(nil)`; no user error — `TogetherDataManager.m:383-393`. - **Poll 401/403/404** → polling off, `CannotSeeRoom`, logs analytics; MainVC kicked dialog + reset — `:367-382`; `MainViewController.m:3181`. - **No host in participants** → kicked: polling off, CannotSeeRoom, logs "no host", `callback(nil)` — `TogetherDataManager.m:306-325`. - **roomInfoOnly newer updated_at** → needsMoreInfo: copy scalars, suppress DidUpdateRoom, immediate full fetch — `:226,236-246`. - **Pending participant rejects (push reject)** → filter that userID if pending, post DidUpdateRoom; only if room_id matches + userID>0 — `TogetherDataManager.swift:157-183`. - **Push update throttled** → ignored if < `minIntervalForTogetherModeNotificationTriggedUpdates` since last push-update — `:144-148`. - **Push chop backgrounded** → local notification + manual roomInfoOnly fetch; foreground → fetch only — `:184-212`. - **Invitations blocked** → handlePushNotification false immediately — `:73-75`. - **chop sync 401/404** → `Plant.update chopNeeded:NO removeRoomId:YES`, completion(YES) — `TogetherDataManager.m:566-575`. - **chop sync 403/other** → completion(NO) → promise rejects → upload fails; retries since chopNeeded still set — `:576-583`; `DataSyncManager.swift:1033-1050`. - **bus give-up AFTER scheduled end** → needsChopper NOT set; no chop — `MainViewController.m:1076-1080`. - **App relaunch mid-session** → `restoreRoomUntilSuccess` loops `fetchRoom(manual)` until model!=nil; bare model with only roomID until first fetch — `TogetherDataManager.m:49-74`. - **isCreatedByCurrentUser lost on restore** → restore builds via initWithJSON; flag local-only, defaults NO; host-only UI (share/kick) keyed on it would not appear — `TogetherRoomModel.h:38-39`; `.m:93-111`. - **Participant equality by userID only** → name/avatar/failAt merged in-place, never add/remove — `TogetherParticipantModel.m:48-55`; `TogetherDataManager.m:260-268`. - **Dummy/cross-server** → userName forced to `different_server_user`; avatar may be nil — `TogetherParticipantModel.m:28-34`. - **Unknown tree_type** → cedar/bush fallback — `TogetherRoomModel.m:47-53`. - **Avatar reload churn** → reload only when userID changes; `prepareForReuse` calls `reset` but does NOT cancel image load (commented out) — `TogetherHeaderFriendView.m:107-112,138-143`. - **Removal animation** → animate dismissal then 0.3s deletes; serial queue (maxConcurrent 1) over `room.duplicate()`; op completes only when BOTH content+inset callbacks fire — `MainHeaderViewController.swift:35-40,143-282`. - **Empty/single participant layout** → host displayCount 1 sets contentWidth=avatarSize; ≤5 centered; >5 scrolls — `MainHeaderViewController.swift:161-189`. > **Leftapp edge case REFUTED & dropped:** the claim that `leftInWorkingWithPhoneMode` (hasLeft=YES) sets `cnstPreferenceShouldShowFailedResultViewForLastPlant` was refuted — those are two unrelated mechanisms. The deep-focus failed-result-view flag is set only in `applicationWillTerminate` (deep-focus, via `onGiveup:`) at `AppDelegate.m:880-884`, read at `MainViewController.m:405-426`. `leftInWorkingWithPhoneMode` (`MainViewController.m:2353-2372`) is the non-deep-focus work-with-phone path scheduled at `AppDelegate.m:678,714`. ### Gates - `participantsFinalized` (guest or host after start) → avatar tap opens profile only; kick suppressed — `TogetherHeaderFriendView.m:71-94`; `TogetherRoomModel.m:199-203`. - `isCreatedByCurrentUser` and not started → share button, room-token message, **leading** Invite cell (item 0), kick allowed — `MainHeaderViewController.swift:107-117,309-324,287-290`. (Corrected: invite cell is the LEADING cell at item 0, not trailing.) - `shouldRejoinTogetherRoomAutomatically` → 409 rejoin vs alreadyJoined — `TogetherDataManager.swift:345-351`. - `maxTogetherRoomStatusPullingInterval` / `shouldRandomize...` → poll interval bound + randomize [0,bound] — `TogetherDataManager.m:644-648`. - room_type chartered vs bus + bus ended → local give-up triggers chop (chartered always; bus only before scheduled end) — `MainViewController.m:1079-1080`. - `successRateThreshold` (shuttle_bus) → `maxNumberFailedParticipantsAllowed = N*(100-threshold)/100 + 1`, default 100. **This value is display-only** (`TogetherRoom+ShuttleBusMessages.swift:42`); the actual verdict is `_isSuccess` parsed from server (`TogetherRoomModel.m:41`), **except** a local `failAt` on the current user forces `isSuccess=NO` regardless of the server verdict (`Plant.m:496-498`) — `TogetherRoomModel.m:67-71,205-207`. ### Limitations - Live updates are POLL-based (one-shot NSTimer rescheduled), not sockets; latency bounded by `maxTogetherRoomStatusPullingInterval`; APNs only nudge a manual fetch — `TogetherDataManager.m:622-650`; `.swift:139-212`. - kit `RoomEndpoints`/Sync/Chop are a separate sync-time pipeline; live loop uses native `HttpRequestHelper` — `TogetherDataManager.m:77-619` vs kit `RoomEndpoints.kt`. - Header scrolling: host scrolls when `displayCount > 5` (displayCount = other participants + invite button when not started, so effectively triggers at ~5 participants); guest scrolls when `otherParticipants.count > 5`; otherwise centered. (Corrected host detail.) — `MainHeaderViewController.swift:166,182`. - Only 3 room types modeled; any other string → chartered — `TogetherRoomModel.m:77-88`. - `failAt` parsed with fixed format `yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ`; other formats → nil failAt (not shown as chopper) — `TogetherParticipantModel.m:13-22,39-41` (chopper gating `:57-60`). - `isCreatedByCurrentUser` local-only, not persisted/copied; reliable only within original session — `TogetherRoomModel.h:38-39`; `.m:93-111`. - `chopPlant` uses `plant.endTime`; bails out (return without callback) if `plant.roomId` nil/≤0 → upload promise left pending — `TogetherDataManager.m:538-542`. --- ## 8. Sub-flow: Finish-chop-success (ending a session: success vs failure) ### Summary Ending resolves success/failure per-participant and per-room. The GROUP tree's life/death is decided server-side: room JSON carries `is_success` + `chopper_user_id`; `success_rate_threshold` (default 100) governs tolerance. The INDIVIDUAL outcome is local: give-up first → `PUT /rooms/{id}/chop` (`chopPlant`) marking self chopper; another chops first → observe `chopper_user_id>0` → `onTogetherPlantBeingChopped`. Clean success marks local plant success, server reconciles via `Plant.updateTogetherPlants()`. iOS chop is legacy Obj-C `TogetherDataManager`/`DataSyncManager`; kit `ChopFailureRoomUseCase` is Android/shared. ### Entry points - Give up — `MainViewController.m:1052`. - Timer completes — `:1290`. - Another chops; room update chopper_user_id>0 — `:3081`. - Chop push — `TogetherDataManager.swift:184`. - Background sync upload of needsChopper plant — `DataSyncManager.swift:1027,1038`. - [kit/Android] `ChopFailureRoomUseCase` — `DefaultPushAndSyncPlantsWithAssetsUseCase.kt:26`. ```mermaid stateDiagram-v2 [*] --> GrowingState GrowingState --> FinishedGiveUp : "give up (needsChopper if room && (chartered or bus not ended))" GrowingState --> FinishedSuccess : "timer completes" GrowingState --> FinishedChoppedByOthers : "poll endTime + chopper while GrowingState" GrowingState --> StartStateCleared : "endTime past, chopper==0, plant.roomId != room.roomID" FinishedGiveUp --> ChopPut : "sync uploadPlant (needsChopper==YES) -> PUT /chop then postPlant" ChopPut --> NeedsChopperCleared : "200 (chopper recorded, polling off)" ChopPut --> RoomDropped : "401/404 (needsChopper cleared + roomId removed, completion YES)" ChopPut --> RetryNextSync : "403/other (completion NO, promise rejects, needsChopper stays)" RetryNextSync --> ChopPut : "next sync" FinishedSuccess --> Reconciled : "updateTogetherPlants (inherit room.isSuccess unless own failAt)" FinishedChoppedByOthers --> Reconciled NeedsChopperCleared --> Reconciled RoomDropped --> Reconciled Reconciled --> [*] StartStateCleared --> [*] ``` ### Happy path 1. Give up — `MainViewController.m:1052`. 2. stopFocus, set hasLeft, isSync=NO, `setPlantFailWithTime:` — `:1053-1069`. 3. If in room AND (chartered OR bus not past scheduled end) → `needsChopper=@YES`, save — `:1075-1090`. 4. switchState:FinishedState, sync — `:1098-1109`. 5. Show give-up result view — `:1113`. 6. Sync `uploadPlant` sees needsChopper → `chopPlant` before `postPlant` — `DataSyncManager.swift:1033-1050`. 7. `chopPlant` reads `plant.endTime`, `PUT /rooms/{roomId}/chop?end_time=...` — `TogetherDataManager.m:538-547`. 8. 200 → records chopper, clears needsChopper, posts DidChop, disables polling, completion(YES) — `:549-560`. 9. [Success] timer completion → isSuccess=YES, all trees alive, endTime=start+duration; no needsChopper — `MainViewController.m:960-981,1290-1356`. 10. [Other chops] setRoom endTime+chopper while GrowingState → `onTogetherPlantBeingChopped` fails at room.endTime, stop polling, OthersGiveUp result — `:3081-3085,1136-1176`. 11. Later sync `downloadAndProcessTogetherRooms` then `Plant.updateTogetherPlants()` reconciles isSuccess/endTime/tree-alive from room — `DataSyncManager.swift:874,888`; `Plant.m:481-538`. ### States GrowingState (Together); FinishedState (give-up / chopped-by-others / success); Background chop notification. Refs: `MainViewController.m:3082,1149-1175,978-981`; `TogetherDataManager.swift:197-211`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | GrowingState | give up | FinishedState (give-up) | needsChopper if room and (chartered or bus not ended) | | GrowingState | timer completes | FinishedState (success) | — | | GrowingState | endTime+chopper | FinishedState (chopped by others) | `state==GrowingState` | | StartState | endTime past, chopper==0, plant.roomId != room.roomID | StartState (room cleared) | already-finished success skip | | FinishedState (needsChopper) | sync uploadPlant | chop PUT then postPlant | `needsChopper==YES` | | chop PUT | 200 | needsChopper cleared, polling off | — | | chop PUT | 401/404 | needsChopper cleared + roomId removed, completion(YES) | resolved | | chop PUT | 403/other | completion(NO); promise rejects | retried next sync | ### Edge cases - **Group decision** → server-side via `is_success` + `chopper_user_id` + `success_rate_threshold` (default 100) + participant `failed_at`; client only displays/reconciles — `TogetherRoomModel.m:35-71`. - **Threshold math** → `maxNumberFailedParticipantsAllowed = N*(100-threshold)/100 + 1` (int division); `currentSuccessRate = round((1 - failed/total)*100)`. Display helpers; do NOT set is_success — `TogetherRoomModel.m:205-211`; `TogetherRoom+ShuttleBusMessages.swift:41-42`. - **Individual vs group** → in `updateTogetherPlants`, if current user has failAt → forced isSuccess=NO, endTime=failAt regardless of room.isSuccess; else inherit room.isSuccess — `Plant.m:491-507`. - **Chopper marking** → `chopper_user_id>0` sets matching participant's failAt=endTime → isChopper; single chopper field — `TogetherRoomModel.m:57-63`; `TogetherParticipantModel.m:57-60`. - **Bus past scheduled end on give-up** → needsChopper NOT set; no chop — `MainViewController.m:1076-1080`. - **chop 401/404** → success-resolution: clears needsChopper + removes roomId, completion(YES); postPlant proceeds; room association dropped — `TogetherDataManager.m:566-575`. - **chop 403/other** → completion(NO) → rejects `ForestAPIError.custom("Chop Error")`; postPlant skipped; needsChopper stays → retry — `:576-583`; `DataSyncManager.swift:1043-1044,1050`. - **chop non-200 success (e.g. 204)** → completion(NO); only 200 clears needsChopper — `:548-563`. - **chop with no/invalid roomId** → early return inside `performBlockAndWait` WITHOUT completion → upload promise hangs (never proceeds to postPlant) — `:540-542`. - **Chop detected while not GrowingState** → `onTogetherPlantBeingChopped` only when `state==GrowingState`; else skipped, reconciled later — `MainViewController.m:3081-3085`. - **Room grew successfully while idle on Start** → setRoom early-returns, clears _room (StartState), stops polling if current room — `:3063-3071`. - **Chop push foregrounded** → no local notification (only scheduled in background); re-fetch via `fetchRoom(true,manual:true)` drives setRoom — `TogetherDataManager.swift:197-211`. - **Chop push different/last room** → returns false if no currentRoom / mismatched room_id / no name — `:185-195`. - **DidChopThePlant** → posted after own chop (userInfo nil), disables polling; no observer found in Forest/ besides declaration → no consumer — `TogetherDataManager.m:552-558`; `.h:37`. - **Tree-alive count** → multi-tree `numOfTreeAlive = duration/cnstMinPlantTimeForMoreBigTree` from (failAt or room.endTime)-startTime; if plantingTime≤threshold → 0 (fail) or 1 (success); single-tree dead = !isSuccess — `Plant.m:509-535`. - **Offline give-up then local delete before sync → chop signal permanently lost** → give-up sets `needsChopper=YES` on the plant (`MainViewController.m:1081`), but `Plant.delete` is a **hard delete** (`Plant.swift:356`), so the record no longer matches `Plant.unsynced()`'s `isSync=NO AND endTime0 && roomId>0 && !isSuccess`, concurrency 10; successful/roomId≤0 skipped — `DefaultChopFailedRoomsUseCase.kt:23-26`. - **[kit] chop error handling** → CancellationException rethrown immediately; other Exception stored in lastError (last wins, earlier lost), rethrown once wrapped — `DefaultChopFailedRoomsUseCase.kt:30-44`. ### Limitations - `success_rate_threshold` percent int, default 100; shuttle_bus/random only; chartered uses chopper-based per-user failure — `TogetherRoomModel.m:67-71`. - `maxNumberFailedParticipantsAllowed` int division + 1 → approximate display value, not authoritative server rule — `TogetherRoomModel.m:206`. - Single `chopper_user_id` modeled; multiple `failed_at` possible — `:35-63`. - `is_success` computed entirely server-side; client reads it and overrides to NO only for the local user when personally failed — `:41`; `Plant.m:496-498`. - iOS chop is legacy Obj-C `HttpRequestHelper`; kit `ChopFailureRoomUseCase`/`DefaultChopFailedRoomsUseCase` is Android/shared, not referenced in iOS — `TogetherDataManager.m:546`. - `chopPlant` always uses `plant.endTime` as chop end_time; no separate user-fail timestamp — `:544-547`. ### Gates - `plant.needsChopper == YES` → uploadPlant routes through chopPlant before postPlant — `DataSyncManager.swift:1033-1052`. - In a room AND (chartered OR bus scheduled end not passed) → needsChopper set on give-up — `MainViewController.m:1075-1080`. - `applicationState == .background` when chop push → local notification; foreground silent re-fetch — `TogetherDataManager.swift:197-206`. - `state == GrowingState` when chopper detected → `onTogetherPlantBeingChopped` — `MainViewController.m:3082-3083`. - [kit] `serverId>0 && roomId>0 && !isSuccess` → eligible for chopRoom — `DefaultChopFailedRoomsUseCase.kt:24-26`. --- ## 9. Sub-flow: Usage-limits / Subscription gating (TinyTAN/BTS species) > ⛔ **TinyTAN RETIRED (live status):** `TinyTAN.isAvailable` is now permanently **false** (event window elapsed; no new purchases). Every `isAvailable`/`canBePurchased`-gated **purchase-CTA branch below is DEAD** and can be skipped when walking the flows: `JoinNotOwnedCTADialog`, `InvitationCTADialog`, the TinyTAN CTA dialog, and `canBePurchased(TinyTAN)`. Live outcomes collapse to the **cannot-join dead-ends** — `JoinCannotJoinError` / `InvitationCannotJoinAlert` (`MainViewController.swift:1617`) and the inline 483 error. **Existing OWNERS are unaffected** (ownership ≠ availability): an owned TinyTAN still hosts (pre-selected loophole, §2) and joins. Mechanism is CloudConfig date/region-gated (`TinyTAN.swift:5-38`), so this is a config-driven "permanently false", not hardcoded. ### Summary The PlantTogether "usage-limits-subscription" gate is NOT a generic free-vs-Pro wall. It is a per-species restriction: the seven TinyTAN (BTS collab) tree types `TreeType89...TreeType95` are `usageLimitedInTogetherMode` and cannot HOST a Together room, nor can you JOIN a room whose tree type you do not own (server 483). It surfaces as: disabled species/planting cells with toasts during setup, a one-time hosted-room info dialog, a one-time store lightbox coach mark, and CTA/error dialogs on invitation/join that route to the one-off TinyTAN package purchase via `TinyTANViewController`. **No `Constants.subscriptionEnabled`/Pro check gates Together.** The only purchase is the TinyTAN in-app package, gated by `TinyTAN.isAvailable` (region/date/global server). No numeric quotas; "usage limited" is a per-tree-type boolean. Kit `RoomEndpoints.kt` has no subscription/usage-limit logic. **Cloud-config caveat:** the server-side ownership check that produces 483 is *not* absolute — during the `FOREST_2024_BIRTHDAY_*` window (a runtime `Constant`, default 2024-05-23→05-31), the tree-not-owned check is skipped for `birthday_2024_free_plant` species, so a non-owner can join/host those. This and every other cloud-tunable value affecting Rooms is inventoried in the server spec §10.8. ### Entry points - Setup species picker while hosting → TinyTAN cells disabled — `PlantingSetupViewController.swift:241`. - Tap TinyTAN species while hosting — `PlantingSetupViewModel.swift:92`. - Tap TinyTAN favorite planting while hosting — `FavoritePlantingViewModel.swift:77`. - Room creation completion → one-time info dialog — `MainViewController.m:2317`. - Join 483 → not-owned CTA/error — `MainViewController.m:3168`. - Incoming TinyTAN unowned invitation → CTA or cannot-join — `MainViewController.swift:1558`. - Store lightbox for TinyTAN → coach mark — `LightBoxViewController.swift:274`. - Tap together-limited badge — `StoreNormalSpeciesLightBoxItemView.swift:431`. ```mermaid stateDiagram-v2 [*] --> SpeciesCellDisabled : "host setup, TreeType89..95 (usage-limited)" SpeciesCellDisabled --> AttemptedSelectionToast : "tap disabled TinyTAN while hosting" SpeciesCellDisabled --> SwitchedAwayToast : "switch from TinyTAN to non-limited" [*] --> HostedRoomLimitInfoDialog : "room created (host) with TinyTAN && !shown" [*] --> JoinNotOwnedCTADialog : "join 483 && TinyTAN.isAvailable" [*] --> JoinCannotJoinError : "join 483 && !TinyTAN.isAvailable" [*] --> InvitationCTADialog : "invitation unowned + limited + canBePurchased" [*] --> InvitationCannotJoinAlert : "invitation unowned + limited + !canBePurchased" [*] --> CoachMark : "lightbox appeared, didShow==false && TinyTAN badge visible" InvitationCTADialog --> TinyTANViewController : "accept (declines invitation first, does NOT join)" InvitationCTADialog --> Declined : "reject (declineInvitation)" JoinNotOwnedCTADialog --> TinyTANViewController : "primary (TinyTAN.isAvailable)" CoachMark --> CoachMarkShown : "drawCoachMarks -> didShow=true" TinyTANViewController --> [*] JoinCannotJoinError --> [*] InvitationCannotJoinAlert --> [*] ``` ### Happy path 1. Gate predicate: `TreeType89...95` → `usageLimitedInTogetherMode==true` — `TreeType.swift:425`. 2. Setup: `isUsageLimited = usageLimitedInTogetherMode && isHostingRoom` → cell `.disabled` when limited and not selected — `PlantingSetupViewController.swift:241`. 3. Tap disabled while hosting → `attemptedToSelectDisabledSpecies`, no change — `PlantingSetupViewModel.swift:92`. 4. Toast `plant_set_toast_package_11_create_room` — `PlantingSetupViewController.swift:277`. 5. Switch from TinyTAN to non-limited → `didChangeSpeciesFromDisabledSpecies`, allowed — `PlantingSetupViewModel.swift:101`. 6. Toast `plant_set_toast_package_11_change_tree` — `PlantingSetupViewController.swift:287`. 7. Favorite planting same gate — `FavoritePlantingViewController.swift:101`; `FavoritePlantingViewModel.swift:83`. 8. After hosting created → `showTinyTANTogetherLimitDialogIfNeeded(newRoom)` — `MainViewController.m:2317`. 9. If not shown once AND TinyTAN → one-time info dialog (`dialog_package_11_plant_together_limit_*`, `confirm_button_got_it`), set flag — `MainViewController.swift:1520`. 10. Join 483 (don't own tree) → `JoinRoomError.treeTypeNotOwned` → `TogetherDataManagerErrorTypeTreeTypeNotOwned` — `TogetherDataManager.swift:354,285`. 11. → `showTogetherJoinRoomTypeTreeTypeNotOwnedErrorDialog` — `MainViewController.m:3168`. 12. If `TinyTAN.isAvailable` → CTA dialog opening `TinyTANViewController`; else `dialog_package_11_cannot_join` — `MainViewController.swift:1594`. 13. Incoming invitation: `!product.isUnlocked && usageLimitedInTogetherMode` → branch on `canBePurchased` — `MainViewController.swift:1558`. 14. canBePurchased → `showTinyTANTogetherCTADialog`; else cannot-join; both call `didProcessPendingInvitation()` — `:1565`. 15. CTA accept → `showTinyTANEventPage` declines invitation then presents `TinyTANViewController` (does NOT join); reject → declineInvitation — `TinyTANTogetherInvitationCTAViewController.swift:244`. 16. Store lightbox: `togetherLimitedButton.isHidden = !usageLimitedInTogetherMode` — `StoreNormalSpeciesLightBoxItemView.swift:317`. 17. On didAppear, if coach-mark `didShow` false → `showOnBoardView` — `LightBoxViewController.swift:274`. 18. `showOnBoardView`: only when selected item is `StoreNormalSpeciesLightBoxItemView` with visible badge — `:281`. 19. `drawCoachMarks` sets `didShow=true` — `TinyTANTogetherUsageLimitedCoachMarkViewController.swift:39`. 20. Tap badge → `showTogetherUsageLimitedDialog` (`package_11_plant_together_limit`) — `StoreNormalSpeciesLightBoxItemView.swift:431`. ### States SpeciesCellDisabled; FavoritePlantingDisabled; AttemptedSelectionToast; SwitchedAwayToast; HostedRoomLimitInfoDialog; JoinNotOwnedCTADialog; JoinCannotJoinError; InvitationCTADialog; InvitationCannotJoinAlert; LightboxTogetherLimitedBadge; CoachMark. Refs: `PlantingSetupViewController.swift:243,277,287`; `MainViewController.swift:1524,1595,1617,1569`; `TinyTANTogetherInvitationCTAViewController.swift:20`; `StoreNormalSpeciesLightBoxItemView.swift:317`; coach mark `.swift:39`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | SpeciesCellDisabled | tap disabled TinyTAN while hosting | AttemptedSelectionToast | `isHostingRoom && usageLimitedInTogetherMode` | | SpeciesCellDisabled | switch from TinyTAN to non-limited | SwitchedAwayToast | hosting && current limited && new not limited | | InvitationCTADialog | accept | TinyTANViewController | `canBePurchased` | | InvitationCTADialog | reject | Dismissed (declined) | — | | JoinNotOwnedCTADialog | primary | TinyTANViewController | `TinyTAN.isAvailable` | | Join 483 | `TinyTAN.isAvailable==false` | JoinCannotJoinError | — | | Invitation processed | unowned && limited && canBePurchased | InvitationCTADialog | — | | Invitation processed | unowned && limited && !canBePurchased | InvitationCannotJoinAlert | — | | Room created (host) | TinyTAN && !shown | HostedRoomLimitInfoDialog | — | | Lightbox appeared | didShow==false && TinyTAN badge visible | CoachMark | — | | CoachMark | drawCoachMarks | didShow=true | — | ### Edge cases - **Selected species is itself TinyTAN while hosting** → cell renders `.selected` (isSelected precedes isUsageLimited); limited type can stay selected though new selection blocked — `PlantingSetupViewController.swift:243`. - **Not hosting (solo)** → isHostingRoom false; TinyTAN fully selectable — `PlantingSetupViewModel.swift:92`. - **Hosted info dialog already shown** → returns immediately (`MainView:TinyTANTogetherLimitDialog:Shown`); never again — `MainViewController.swift:1521`. - **Hosted room NOT TinyTAN** → returns early; no dialog — `:1522`. - **Coach mark already shown** → `didShow` true (`TinyTAN:didShowTogehterUsageLimitedCoachMark`) → not called — `LightBoxViewController.swift:275`. - **Lightbox item not TinyTAN / badge hidden** → guard fails; not presented; didShow stays false → can show later — `:281`. - **Join 483 but `TinyTAN.isAvailable` false** → no CTA; `dialog_package_11_cannot_join` dead-end — `MainViewController.swift:1617`. - **Invitation unowned, limited, !canBePurchased** → `showErrorAlert` cannot-join; still calls `didProcessPendingInvitation()` via defer — `:1568`. - **Invitation no species/product** → guard fails; `completion?()` and return; pending NOT cleared (defer after guard) — `:1542`. - **Invitation tree owned** → skips limit branch; normal accept flow — `:1558`. - **CTA accept** → declines invitation first, then opens purchase; does NOT join — `TinyTANTogetherInvitationCTAViewController.swift:244`. - **Join 409 + shouldRejoin** → treated as success, re-maps room (shares join switch) — `TogetherDataManager.swift:347`. - **Network/non-mapped join status** → default → `unknown` → `.typeUnknown` → `fail_message_together_unknown` — `:358`. - **Switching between two TinyTAN while hosting** → same → nothing; both limited different → newTreeType limited branch fires `attemptedToSelectDisabledPlantings`, blocks — `FavoritePlantingViewModel.swift:81`. - **TinyTAN.isAvailable nil SKStorefront** → countryCode defaults "ZZZ", byIntention=false; availability false if ZZZ excluded — `TinyTAN.swift:16`. > NOTE: A claim that "outside date window or non-global server → all purchase CTAs collapse to cannot-join error path" was **REFUTED** — when `isAvailable` is false, callers merely SUPPRESS TinyTAN promotion/entry UI; they do not route into the cannot-join path (that path is the unrelated `treeTypeNotOwned` join-time gate at `TogetherDataManager.swift:303`). Dropped. ### Limitations - NO generic Pro/subscription gate on PlantTogether *access*; `Constants.subscriptionEnabled` only governs the subscription entry icon (`MainViewController.swift:456-484`; `Constants.swift:35-40`). **Caveat:** the Together-mode Start footer DOES route a locked/seasonal-exclusive *species* to Store/paywall — see §12.7 Footer Gating. That gates the selected tree, not Together itself. - "Usage limit" is a hardcoded per-tree-type boolean over `TreeType89...95`, not a quota/counter/cap — `TreeType.swift:425-432`. - HOST-only on client: only `isHostingRoom` triggers disable/toast. Joining with a TinyTAN tree allowed if owned; else server 483 — `PlantingSetupViewController.swift:241`; `TogetherDataManager.swift:354`; `TogetherDataManager.h:25`. - Hosted info dialog + lightbox coach mark strictly one-time per device (UserDefaults, account-independent) — `MainViewController.swift:1514`; `TinyTANTogetherUsageLimitedCoachMarkViewController.swift:6-19`. - Unlock path is a one-off TinyTAN in-app package via `TinyTANViewController` (not subscription); `canBePurchased` for TinyTAN types = `TinyTAN.isAvailable` — `TreeType.swift:434`; `TinyTANPurchaseManager.swift`. - Kit `RoomEndpoints.kt` (`api/endpoints/legacy/RoomEndpoints.kt:1-224`) has no subscription/usage-limit/ownership logic; 483 enforced server-side, consumed only by iOS. (The "483" is an iOS-side status code, not a kit line/symbol.) - *[server]* **Ownership enforcement (forest-server):** the not-owned block is driven by a per-`TreeType` boolean `owners_only_in_rooms` (not hardcoded to 89–95), checked at join time in `participate` → **483** (`rooms_controller.rb:186-188`); applies to any `room_type`. **Host** create/update return **423** for an unowned tree (`:32-49`, `:70-91`) — a *different* status code from the joiner's 483 for the same "tree not owned" concept. The **invite** endpoint performs no ownership check (`:108-139`). - Coach mark renders desc + anchors to first rect; if rects empty returns without drawing (didShow already gates presentation) — `TinyTANTogetherUsageLimitedCoachMarkViewController.swift:40`. ### Gates - `isHostingRoom AND TreeType89...95` → cell disabled + switch blocked with toast, BUT only on an active selection attempt; an already-selected TinyTAN renders `.selected` (isSelected precedes isUsageLimited, `PlantingSetupViewController.swift:241`) and can still create/host — no guard on the create/update POST — `PlantingSetupViewModel.swift:92`; `TreeType.swift:425-432`; `TogetherDataManager.m:76-82`. - Joining a room whose tree you don't own → server 483 → treeTypeNotOwned → CTA/error — `TogetherDataManager.swift:354`. - Invitation TinyTAN + not unlocked → blocks normal accept; CTA (if canBePurchased) or cannot-join — `MainViewController.swift:1558`. - `TinyTAN.isAvailable` false (excluded region/date/non-global) → no CTA; `dialog_package_11_cannot_join` — `TinyTAN.swift:5`. - `canBePurchased`: only TinyTAN conditional (on isAvailable); others always purchasable — `TreeType.swift:434`. --- ## 10. Sub-flow: Sync-restore (persistence & reconciliation of room records / chop-failure) ### Summary Persistence/sync of Together room records + restoring an in-progress/completed session after kill/relaunch/network loss. TWO parallel implementations. (1) LIVE iOS path: singleton `TogetherDataManager` holds in-memory active room, polls `/api/v1/rooms/{id}`; relaunch restore from `MainViewController.resumeLastPlanting` via `Plant.roomId`; chop-failure reconcile in `DataSyncManager.uploadPlant` → `chopPlant` for needsChopper plants; read-model sync `downloadAndProcessTogetherRooms` persists rooms into CoreData `TogetherRoom` keyed on roomID with `updated_at` high-water mark. (2) stforestkit shared code (`DefaultSyncRoomRecordsUseCase`, `DefaultChopFailedRoomsUseCase`, `RoomEndpoints`) wired in SyncModule but its Local* bindings are NOT provided on iOS — effectively Android/future, not running on iOS. ### Entry points - App relaunch/resume with ongoing plant with roomId — `MainViewController.m:847` → `:872-873`. - `restoreRoom:` → immediate manual fetch loop, then auto-poll — `TogetherDataManager.m:49,64`. - Full sync read-model pull — `DataSyncManager.swift:475` (called from `:874`). - Push update/chop/reject re-fetch — `TogetherDataManager.swift:69`. - Offline-ended plant reconcile during upload — `DataSyncManager.swift:1027,1038`. - [kit/Android] QuickSync fans out syncRoomRecords — `DefaultQuickSyncUseCase.kt:86`. - [kit] `DefaultChopFailedRoomsUseCase.invoke` (no commonMain caller) — `:20`. ```mermaid stateDiagram-v2 [*] --> NoActiveRoom NoActiveRoom --> Restoring : "resumeLastPlanting finds roomId>0 after timer restore" NoActiveRoom --> RestoreAbandoned : "timer restore fails (restoreRoom never called)" Restoring --> Restoring : "fetchRoom nil while restoring (retries, no backoff)" Restoring --> ActivePolling : "fetchRoom returns model" ActivePolling --> ActivePolling : "poll cycle completes (!isManual && autoPolling)" ActivePolling --> CannotSeeRoomKicked : "fetch 401/403/404 or no host" ActivePolling --> RoomEndedSuccess : "restored room already ended (endTime past, chopper==0, roomId mismatch)" ActivePolling --> BeingChopped : "chopper while growing (endTime!=nil && chopper>0)" ActivePolling --> PlantPendingChop : "local give-up/finish (chartered || scheduledEnd future)" PlantPendingChop --> NoActiveRoom : "uploadPlant chop 200 / chop 401/404 (roomId removed)" CannotSeeRoomKicked --> [*] RoomEndedSuccess --> [*] BeingChopped --> [*] RestoreAbandoned --> [*] ``` ### Happy path 1. Relaunch with ongoing session — `MainViewController.m:847`. 2. `resumeLastPlanting` loads lastPlant; bails if nil/no startTime/future, zeroing `PrefOngoingTotalPlantingTime` — `:856-860`. 3. Restore timer; only on success AND roomId>0 → `restoreRoom:` — `:865-874`. 4. `restoreRoom:` guards double-restore, builds stub `_room` (roomID only), `_shouldRestoreRoom=YES` — `TogetherDataManager.m:49-62`. 5. `restoreRoomUntilSuccess` → `fetchRoom(roomInfoOnly:NO, manual:YES)` → `GET /api/v1/rooms/{id}?detail=true` — `:64-74,189-211`. 6. 200 → full reconciliation (merge/sort, clear pending, mark choppers, copy fields) — `:253-345`. 7. Post DidUpdateRoom; if no model and still restoring retry; else `_shouldRestoreRoom=NO` + auto-poll — `:347-353,66-72`. 8. `setRoom` applies; ended+chopper while Growing → onTogetherPlantBeingChopped; already-ended-success → skip — `MainViewController.m:3046-3090`. 9. Auto-poll: roomInfoOnly when startTime!=nil; one-shot timer each cycle — `TogetherDataManager.m:622-650`. 10. Read-model sync downloads rooms since high-water mark, upserts `TogetherRoom` — `DataSyncManager.swift:475-513`. 11. `updateTogetherPlants` reconciles each Together Plant from its TogetherRoom (using this user's failAt) — `:888`; `Plant.m:481-559`. 12. Give-up → `needsChopper=YES` (chartered always; bus only if not past scheduled end) — `MainViewController.m:1075-1090`. 13. Upload: needsChopper → `chopPlant` `PUT /chop?end_time=...`; 200 clears, then postPlant; disable polling — `DataSyncManager.swift:1033-1050`; `TogetherDataManager.m:538-585`. ### States No active room; Restoring; Active/polling; Room ended (success); Being chopped; Plant pending chop; Cannot-see-room/kicked; Read-model cache. Refs: `TogetherDataManager.m:45-47,56-74,622-650,306-325,367-394`; `MainViewController.m:3063-3071,3081-3085,1081,3181-3184`; `TogetherRoom+CoreDataClass.m:20-77`. ### Transitions | From | Event | To | Guard | |---|---|---|---| | No active room | resumeLastPlanting finds roomId>0 after timer restore | Restoring | `didRestore==YES && plant.roomId.longValue>0` | | Restoring | fetchRoom returns model | Active/polling | `model != nil` | | Restoring | fetchRoom nil while restoring | Restoring | `model==nil && _shouldRestoreRoom` | | Active/polling | poll cycle completes | Active/polling | `!isManual && _isAutoPollingEnabled` | | Active/polling | fetch 401/403/404 or no host | Cannot-see-room/kicked | — | | Active/polling | restored room already ended success | Room ended (success) | endTime past && chopper==0 && plant.roomId != room.roomID | | Active/polling | restored/updated chopper while growing | Being chopped | endTime!=nil && chopper>0 && Growing | | Active/polling | local give-up/finish | Plant pending chop | `_room!=nil && (chartered \|\| scheduledEnd future)` | | Plant pending chop | uploadPlant chop 200 | No active room | `needsChopper==YES` | | Plant pending chop | chop 401/404 | No active room | — | ### Edge cases - **Timer restore fails** → `restoreRoom` NEVER called; zero `PrefOngoingTotalPlantingTime`, show restore-error dialog; room abandoned despite roomId>0 — `MainViewController.m:866-869,883-885`. - **Restore retries forever on transient nil** → `restoreRoomUntilSuccess` recurses on every nil with no backoff/cap; generic/connection failure returns nil → tight loop until network recovers — `TogetherDataManager.m:64-74,383-394`. - **Restore fetch 401/403/404** → disables polling, posts CannotSeeRoom, logs, `callback(nil)` WITHOUT clearing `_shouldRestoreRoom` → retries same failing request indefinitely (CannotSeeRoom notification separately drives MainVC reset, which clears the flag and halts the loop) — `:367-394,64-74`. - **No host on restore** → fatal: disable polling, CannotSeeRoom, "no host", callback(nil) — `:306-325`. - **Room ended success mid-restore** → setRoom short-circuits, stops polling if current, clears `_room` only if StartState; in GrowingState does not clear — `MainViewController.m:3063-3071`. - **chop 403 (not logged in)** → completion(NO); needsChopper stays, roomId retained, retry later; uploadPlant rejects → postPlant skipped — `TogetherDataManager.m:576-579`; `DataSyncManager.swift:1043-1044`. - **chop 401/404** → needsChopper=NO + roomId removed, completion(YES) → ordinary plant; room association dropped — `TogetherDataManager.m:566-575`. - **chop 200 but other status in success callback** → only 200 acts; else completion(NO), preserved for retry; failure default also completion(NO) — `:548-563,580-582`. - **needsChopper conditional on bus end** → bus/birthday don't set needsChopper if scheduledEnd passed; only chartered always chops — `MainViewController.m:1076-1090`. - **Read-model TogetherRoom 403** → reject notAuthenticated; combined `all()` fails → plant sync timestamp NOT advanced — `DataSyncManager.swift:490-492,871-893`. - **Read-model non-dict/malformed** → whole response not array → invalidServerResponse; individual element failing init silently skipped — `:495-507`. - **High-water mark** → `lastUpdateDate` = max updatedAt across all rows; from_date omitted when table empty (full fetch); no per-room delta — `TogetherRoom+CoreDataClass.m:31-45`; `DataSyncManager.swift:478-481`. - **Push update throttling** → ignored within `minIntervalForTogetherModeNotificationTriggedUpdates`; ignored if no currentRoom; roomInfoOnly when startTime!=nil — `TogetherDataManager.swift:139-156`. - **Push chop/reject mismatched room** → require currentRoom + matching room_id (reject also valid user_id) else false; chop local notification only in background — `:157-212`. - **Invitations blocked** → handlePushNotification false immediately — `:73-75`. - **roomInfoOnly newer data** → needsMoreInfo suppresses DidUpdateRoom, chains full fetch — `TogetherDataManager.m:226-246`. - **[kit] ChopFailedRooms partial failure** → concurrency 10; non-cancellation Exception swallowed to lastError (last wins), rethrown wrapped; CancellationException rethrown immediately — `DefaultChopFailedRoomsUseCase.kt:21-44`. - **[kit] SyncRoomRecords never advances own watermark** → reads getLastSyncTime, never updateLastSyncTime; watermark advanced only as side effect of host `updateRooms`, else refetches from same point — `DefaultSyncRoomRecordsUseCase.kt:17-25`; `SyncRoomRecordsUseCaseImplTest.kt:156-158`. - **[kit] QuickSync room sync fire-and-forget within background scope** → bare `launch{}` child (no completion signaling); a thrown SyncError fails whole QuickSync background phase via `wrapWithErrorHandling`, unlike the plant chain — `DefaultQuickSyncUseCase.kt:62-96`. ### Limitations - iOS does NOT use kit room sync/chop; `LocalRoomRepository`/`LocalLastSyncRoomTimeRepository` have no binding reachable on iOS (only `RemoteRoomRepository` bound, `SyncModule.kt:145`); kit room use cases' local deps unsatisfiable on iOS — `SyncModule.kt:145,212-217,285-291`. - Live `_room` is in-memory only; reconstructed purely from server on relaunch via `Plant.roomId`; losing roomId loses the link — `TogetherDataManager.m:16,45-62`; `MainViewController.m:872-873`. - Restore depends entirely on timer restore succeeding first; failed timer restore abandons room with no retry — `MainViewController.m:865-887`. - Auto-poll = single one-shot NSTimer per cycle, bounded by `maxTogetherRoomStatusPullingInterval`, optionally randomized; no exponential backoff; connection errors reschedule at same interval — `TogetherDataManager.m:642-650,385-387`. - TogetherRoom CoreData cache separate from live polling; stores raw JSON + only max updatedAt global high-water mark (no per-room since-tracking) — `TogetherRoom+CoreDataClass.m:20-64`; `DataSyncManager.swift:478-507`. - Chop-failure reconcile only fires during upload (needsChopper gate); no standalone background sweep on iOS (unlike kit's `getUnSyncPlants` sweep) — `DataSyncManager.swift:1027-1057`. - kit `getRooms` uses to=now, from=lastSync; `chopRoom` passes end_time query param; both kit + iOS hit same legacy REST contract — kit `RoomEndpoints.kt:66-198`; `TogetherDataManager.m:546`. - needsChopper only set on give-up/finish via onGiveup; success-completed plants reconciled via `updateTogetherPlants` — `MainViewController.m:1075-1090`; `Plant.m:481-559`. ### Gates - Together invitations blocked → all push handling skipped → notification-triggered restore/refresh never runs — `TogetherDataManager.swift:73-75`. - `shouldRejoinTogetherRoomAutomatically` (409) → successful rejoin vs alreadyJoined — `:347-351`. - User not logged in → `updateTogetherPlants` skips participant-failAt override (keeps room-level); restore still issues authenticated fetch → 403 → CannotSeeRoom — `Plant.m:491-503`; `TogetherDataManager.m:369-374`. - Server maintenance (5xx + isServerUnderMaintenance) → MaintenanceInProgress on create/update/start/leave/kick — `TogetherDataManager.m:114-116,152-156,473-474,529-531,613-615`. - Room type & scheduled end → needsChopper unconditional for chartered; bus/birthday only if not past scheduled end — `MainViewController.m:1079-1081`. --- ## 11. Cross-flow limitations & open questions ### 11.1 Aggregated open questions - **Exact server algorithm** for shuttle_bus matchmaking, dummy-user count, and how `is_success` is derived from `success_rate_threshold` + participant `failed_at` is server-only; client `maxNumberFailedParticipantsAllowed`/`currentSuccessRate` are display approximations. - **`TogetherDataManagerDidChopThePlantNotification`** is posted after own chop but has no observer in Forest/ besides the declaration — `TogetherDataManager.m:552-558`; `.h:37`. - **kit room sync/chop on iOS at runtime?** No Koin binding for `LocalRoomRepository`/`LocalLastSyncRoomTimeRepository` reachable on iOS; QuickSync's room branch would fail to resolve if run on iOS. Likely Android-only/dead on iOS; binding source not located. `ChopFailureRoomUseCase.invoke()` has no commonMain caller (presumably Android host). - **kit SyncRoomRecords watermark** never advanced inside the use case (`updateLastSyncTime` unused); whether host `updateRooms` advances it is undeterminable from commonMain. - **`restoreRoomUntilSuccess`** has no max-retry/backoff; on persistent 401/403/404 the failure block doesn't clear `_shouldRestoreRoom`, so it retries indefinitely while the separate CannotSeeRoom notification drives `MainVC.reset` — exact interleaving/termination not verified end-to-end. - **Read-model vs live restore concurrency** — whether `downloadAndProcessTogetherRooms` and the live restore/poll path can write conflicting state for the same room (different contexts/threads) not fully traced. - **`numberOfFailedParticipants`/`currentSuccessRate`** divide by `numberOfParticipants` with no zero-guard → potential NaN during a transient empty-list poll (unlikely in-session) — `TogetherRoomModel.m:209-211`. - **Universal-link join** (`continueUserActivity`) currently just returns YES without handling the web URL — `AppDelegate.m:866-868`. - **`_totalPlantTime` sync to manage sheet** — the precise point where create-room duration is synced from Planting Manage's selected time was not fully traced. ### 11.2 Completeness gaps (sub-flows NOT yet specified) The 9 flows above skew toward room ENTRY (create/join/invite/invitation/walkthrough/usage-limit/sync). The following **lifecycle-control** flows on the live native iOS path are NOT yet covered: 1. **Host-start-room** — `PUT /api/v1/rooms/{id}/start`. Finalizes participants (drops pending), posts `DidStartPlanting`, WaitingToStart→InSession. Errors: RoomTooVacant (423, `fail_message_participant_not_enought`), RoomAlreadyStarted (404 → reloadRoomUntilCompleted), InappropriateRole (401, `fail_message_not_a_host`), NotAuthenticated (403). Refs: `TogetherDataManager.m:483-536`; error switch `MainViewController.m:3186-3195`; `:3336-3342`. 2. **Leave-or-cancel-room** — guest Leave / host Cancel-with-confirm-dialog (`cancel_confirm_dialog_*`, destructive). `PUT /api/v1/rooms/{id}/leave` (host = destroy room). Errors: CannotLeaveRoomDueToOutdatedRoomInfo (404 → reload), NotAuthenticated (403). Posts `DidLeaveRoom`, clears room, stops polling. Refs: `StartFooterViewController.swift:141-174,180-221`; `TogetherDataManager.m:588-620`. 3. **Host-kick-participant** — avatar action sheet (`together_host_action_kick_text`) → `PUT /api/v1/rooms/{id}/kick {user_id}`. 200/410 → `DidKickParticipant`. Refs: `TogetherHeaderFriendView.m:63-77`; `MainHeaderViewController.swift:287-290`; `TogetherDataManager.m:459-481`; `MainViewController.m:3344-3357`. 4. **Host-reconfigure-room** — pre-start tree/duration change → `PUT /api/v1/rooms/{id} {tree_type,target_duration,room_type:chartered}`. Success posts `DidChangeRoomConfigurations`; failure (`UpdateRoomConfigurationFailed`) ROLLS BACK the slider/tree-ball UI + shows `fail_message_together_unknown`. Refs: `TogetherDataManager.m:130-159`; rollback `MainViewController.m:3174-3180,2696,3311-3315`. 5. **Deeplink-join** — `forest://join_room?token=...` via `AppDelegate openURL` → `handleURL:` → `pendingToken` → `DidReceiveRoomCode` → auto `joinRoom:`. Share URL is `https://www.forestapp.cc/join-room?token=%@`. Note divergence: `continueUserActivity` (universal-link) currently returns YES without handling. Refs: `AppDelegate.m:902-914,866-868`; `TogetherDataManager.m:659-673`; `MainViewController.m:3291-3298`; `Constants.h:33`. 6. **Share-room-code** — host share button → `showShareCurrentRoomCodeView` → `ForestShareManager activityViewControllerForSharingRoom` builds `UIActivityViewController` (text `together_share_room_code_text_new`, join URL, image, channel restrictions/WeChat-Weibo special-cased, `logShareEvent`). Refs: `ForestShareManager+TogetherRoom.swift:2-37`; `MainViewController.m:2628-2638`. 7. **Rejoin-on-conflict** — 409 + `shouldRejoinTogetherRoomAutomatically` success-vs-error fork in the Moya joinRoom path — `TogetherDataManager.swift:345-351,282`. 8. **Together-footer-gating** — Start footer `goStoreBtn` (`main_view_go_store_btn_text`) + `subscribeToPlusButton` (→ `SubscriptionFlowCoordinator`, `PaywallEntrance.home`) when the selected Together tree is locked/seasonal-exclusive, gating the Invite/create button on `isUnlocked`. This is a real non-TinyTAN ownership/subscription gate the §9 "no Pro wall" claim understates (it gates a generic locked/seasonal species, not Together access per se). Refs: `StartFooterViewController.swift:200-219,261-273,223-226`. 9. **Error-dialog-dispatcher** — centralized `MainViewController` error→localized-alert mapping. Unmapped states include NetworkNotStable (`feedback_loading_failure_title`), RoomNotFound (404, `fail_message_room_not_found`), RoomFull (423, `fail_message_room_is_full`), CannotSeeRoom/kicked (resets room + `kick_out_description`), generic `fail_message_together_unknown`. Refs: `TogetherDataManager.h:8-26`; dispatcher `MainViewController.m:3134-3212`. ### 11.3 Coverage assessment The 9 documented flows give broad coverage of entry/creation/joining/inviting/invitation/walkthrough/usage-limit/sync, and the iOS-native-vs-stforestkit platform-divergence framing is accurate (the kit RoomEndpoints/Sync/Chop use-cases are the parallel Android/future path; the live iOS path is Obj-C `TogetherDataManager` + `HttpRequestHelper` + Moya). **Coverage is NOT complete:** the lifecycle-control surface (start/leave/cancel/kick/reconfigure/share/deeplink) and the centralized error dispatcher are the main blind spot, enumerated in §11.2. --- ## 12. Lifecycle Control Flows (added — validation round 1) These eight endpoints are real, code-verified room-lifecycle controls that were only referenced as single edges (or not at all) in the core diagrams above. Each is modeled here as its own flow. Refs are to current code. ### 12.1 Host Start `PUT /rooms/{id}/start` — host starts the session once enough participants joined. (`TogetherDataManager.m:483-536`, error switch `513-535`) ```mermaid flowchart LR wait["WaitingToStart (host)"] -->|"tap Start, PUT /start"| req["Request"] req -->|"200"| started["InSession (timer runs for all)"] req -->|"404"| e1["RoomAlreadyStarted"] req -->|"403"| e2["NotAuthenticated"] req -->|"401"| e3["InappropriateRole (not host)"] req -->|"423"| e4["RoomTooVacant (under 2 participants)"] ``` ### 12.2 Leave / Cancel `PUT /rooms/{id}/leave` — guest leaves, or host cancels a not-yet-started room. (`TogetherDataManager.m:588-620`) ```mermaid flowchart LR room["In room (guest or pre-start)"] -->|"tap Leave, PUT /leave"| req["Request"] req -->|"200"| left["DidLeaveRoom: clear room + stop polling"] req -->|"404"| e1["CannotLeave (outdated room)"] req -->|"403"| e2["NotAuthenticated"] ``` **Failure behavior (403 / 404) — leave does NOT happen; host stays in the room:** - `_room` is cleared and polling stopped **only on 200** (`TogetherDataManager.m:599-600`). On any failure the room persists and auto-polling keeps running, so the host/guest remains in the room. Spinner starts at `MainViewController.m:2672` (`leaveCurrentRoom` → `SKLoadingView`). - **404 `CannotLeaveRoomDueToOutdatedRoomInfo`** → **no alert**; `shouldStopLoadingView=NO`, so the spinner keeps running while `reloadRoomUntilCompleted` re-fetches the room in the background — the user gets no feedback that leave failed (`MainViewController.m:3196-3199`, stop at `:3214-3224`). - **403 `NotAuthenticated`** → alert **"Please Log In."** (`fail_message_log_in_first`), spinner stops (`MainViewController.m:3147-3149`). Room kept. - The Cancel/Leave button is **never disabled** during the request (no `isEnabled` toggle in `StartFooterViewController`) → re-tapping queues another `PUT /leave`. - Host vs guest: identical failure handling; host only differs in the destructive confirm dialog shown first (`StartFooterViewController.swift:146-174`). ### 12.3 Host Kick `PUT /rooms/{id}/kick {user_id}` — host removes a participant. 200 and 410 both treated as success. (`TogetherDataManager.m:459-481`) ```mermaid flowchart LR host["Host viewing participants"] -->|"tap kick, PUT /kick {user_id}"| req["Request"] req -->|"200"| done["DidKickParticipant: remove from list"] req -->|"410 (already gone)"| done req -->|"other"| err["error dispatched (see 12.8)"] ``` ### 12.4 Reconfigure Room `PUT /rooms/{id}` with hardcoded `room_type:"chartered"` — host changes tree/duration before start. (`TogetherDataManager.m:130-159`; UI rollback `MainViewController.m:3174-3180`) ```mermaid flowchart LR edit["Host edits tree / duration"] -->|"PUT /{id} (room_type=chartered)"| req["Request"] req -->|"success"| ok["DidChangeRoomConfigurations (apply)"] req -->|"failure"| fail["UpdateRoomConfigurationFailed + UI rollback"] ``` ### 12.5 Deeplink Join `forest://join_room?token=` custom scheme auto-joins. Universal-link `continueUserActivity` returns YES but does NOT handle the web URL. (`TogetherDataManager.m:659-673`; `AppDelegate.m:902-914`, no-op `866-868`) ```mermaid flowchart LR url["forest://join_room?token="] --> handle["handleURL -> store pendingToken"] handle --> notify["DidReceiveRoomCode"] notify --> auto["auto-join (PUT /participate)"] univ["universal link (continueUserActivity)"] -->|"returns YES, no handling"| noop["web URL not wired"] ``` ### 12.6 Share Room Code Host shares the room token via the system share sheet. (`MainViewController.m:2628-2638`; `ForestShareManager+TogetherRoom.swift`) ```mermaid flowchart LR host["Host in room"] -->|"tap share code"| share["showShareCurrentRoomCodeView"] share --> sheet["UIActivityViewController (system share sheet)"] ``` ### 12.7 Footer Gating (subscription / seasonal) The Together-mode Start footer gates the Invite/create button on species-unlock (`isUnlocked` = store ownership of the selected tree), routes a locked species to the Store, and routes a seasonal-exclusive species to the subscription paywall. So the gate is primarily **tree ownership** (store purchase); only seasonal-exclusive species route to a subscription. It gates the selected tree, not Together access itself — the generic gate §9 (TinyTAN usage-limit) does not cover. (`StartFooterViewController.swift:200-219`, `261-273`) ```mermaid flowchart LR footer["Together-mode Start footer"] -->|"species locked (isUnlocked==false)"| store["goStoreBtn -> Store"] footer -->|"seasonal exclusive (isSeasonalExclusive)"| plus["subscribeToPlusButton"] footer -->|"unlocked"| invite["inviteBtn enabled"] plus --> paywall["SubscriptionFlowCoordinator / PaywallEntrance.home"] ``` ### 12.8 Error Dispatcher Centralized switch for the **major** Together errors (NOT every defined type). Covers Unknown, NetworkNotStable, InappropriateRole, RoomNotFound, RoomFull, CannotSeeRoom, RoomTooVacant, MaintenanceInProgress (+ RoomAlreadyStarted, CannotLeave). `InvalidCode`, `AlreadyJoined`, `CodeInOtherServer`, `TreeTypeNotOwned` are handled at their origin (e.g. inline in the join dialog), not centrally dispatched. (`MainViewController.m:3134-3212`; enum `TogetherDataManager.h:8-26`) ```mermaid flowchart LR err["Together error dispatched"] --> sw["central switch"] sw --> Unknown sw --> NetworkNotStable sw --> InappropriateRole sw --> RoomNotFound sw --> RoomFull sw --> CannotSeeRoom["CannotSeeRoom (kicked -> reset + kick_out_description)"] sw --> RoomTooVacant sw --> MaintenanceInProgress ``` ### 12.9 Notification-Permission Prompt A system notification-permission prompt fires on entering Together mode, gated `isInTogetherMode && isLogin`. Triggered after walkthrough dismiss (first time), immediately on subsequent Together switches, and on app foreground. (`MainViewController.m:2488-2495`, called from `:2521`/`:2525`/`:401`; prompt `MainViewController.swift:42`) ```mermaid flowchart LR switch["Switch to Together mode"] --> q1{"walkthrough played?"} q1 -->|"no"| wt["Present walkthrough"] wt -->|"onDismiss"| ask["askForNotificationPermissionsIfNeeded"] q1 -->|"yes"| ask fg["App foreground"] --> ask ask --> q2{"isInTogetherMode && isLogin?"} q2 -->|"no"| noop["no-op"] q2 -->|"yes"| prompt["system notification permission prompt"] ``` ### 12.10 Shuttle-Bus Messaging & Countdown Poll `shuttle_bus` rooms have two distinct header-message states keyed off **room state** (StartState vs GrowingState — NOT whether `scheduledStartTime` has elapsed), plus a second on-demand poll trigger separate from the auto-poll timer. The pre-start message shows via a 1s repeating timer the whole time the room is in StartState (even after `scheduledStartTime` passes); the elapsed-by-5s condition is what fires the on-demand poll while still showing the pre-start message. The ongoing message appears only once the room enters GrowingState. (`MainViewController.m:1734-1757`, `2029-2037`, `2092-2100`, `3096-3097`; `TogetherRoom+ShuttleBusMessages.swift:4-48`) ```mermaid flowchart LR joined["In shuttle_bus room"] --> q{"room state?"} q -->|"StartState (pre-departure)"| pre["shuttleBusPreStartMessage via 1s timer"] q -->|"GrowingState (session started)"| ride["shuttleBusOngoingMessage"] pre --> chk{"startTime elapsed over 5s and not polling and throttle ok?"} chk -->|"yes"| poll["updateBusCountdownText on-demand fetchRoom (2nd poll trigger, throttled vs maxTogetherRoomStatusPullingInterval)"] chk -->|"no"| pre poll --> pre ```