diff --git a/app/src/main/java/org/permanent/permanent/Constants.kt b/app/src/main/java/org/permanent/permanent/Constants.kt index fb0f9995..bcc48b42 100644 --- a/app/src/main/java/org/permanent/permanent/Constants.kt +++ b/app/src/main/java/org/permanent/permanent/Constants.kt @@ -19,6 +19,9 @@ class Constants { const val STRIPE_URL = "https://api.stripe.com/v1/payment_intents" const val MY_FILES_FOLDER = "My Files" const val PUBLIC_FILES_FOLDER = "Public" + // Section-root folder types on the V2 wire, post-mapper normalization. + const val MY_FILES_FOLDER_TYPE = "type.folder.private-root" + const val PUBLIC_FILES_FOLDER_TYPE = "type.folder.public-root" const val PRIVATE_FILES = "Private Files" const val PUBLIC_FILES = "Public Files" const val MEDIA_TYPE_JSON = "application/json;charset=UTF-8" diff --git a/app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt b/app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt index 071ad0e7..1d015d3f 100644 --- a/app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt +++ b/app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt @@ -92,12 +92,13 @@ private fun ItemDTO.isHeicOriginal(): Boolean { return name.endsWith(".heic") || name.endsWith(".heif") } -// Folders answer with new short type forms ("private", "root.private"…) while records +// Folders answer with new short type forms ("private", "private-root"…) while records // keep the legacy dotted forms ("type.record.image"). Normalize folders back to the -// dotted form so downstream consumers and the type sort see V1-shaped values. +// dotted form so downstream consumers and the type sort see V1-shaped values; +// underscore spellings are canonicalized to hyphens here (iOS tolerates both too). private fun ItemDTO.normalizedBackendType(isFolder: Boolean): String? = when { type == null -> null - isFolder && !type.startsWith("type.") -> "type.folder.$type" + isFolder && !type.startsWith("type.") -> "type.folder.${type.replace('_', '-')}" else -> type } diff --git a/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt b/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt index cbf33bed..ea20af5b 100644 --- a/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt +++ b/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt @@ -35,6 +35,7 @@ import org.permanent.permanent.models.Tag import org.permanent.permanent.models.Tags import org.permanent.permanent.network.models.AccountVO import org.permanent.permanent.network.models.ArchiveSteward +import org.permanent.permanent.network.models.ArchivesV2Response import org.permanent.permanent.network.models.ChecklistResponse import org.permanent.permanent.network.models.FileData import org.permanent.permanent.network.models.FolderChildrenResponse @@ -852,6 +853,8 @@ class NetworkClient(private var okHttpClient: OkHttpClient?, context: Context) { pageSize: Int = StelaAccountService.MAX_CHILDREN_PAGE_SIZE ): Call = stelaAccountService.getFolderChildrenV2(folderId, pageSize) + fun getArchivesV2(): Call = stelaAccountService.getArchives() + fun getRecordV2(recordId: Int): Call = stelaAccountService.getRecord(recordId) fun getFolderV2(folderId: Int, shareToken: String? = null): Call = diff --git a/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt b/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt index e3a381c2..0bcbb05e 100644 --- a/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt +++ b/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt @@ -2,7 +2,9 @@ package org.permanent.permanent.network import okhttp3.RequestBody import okhttp3.ResponseBody +import org.permanent.permanent.models.AccessRole import org.permanent.permanent.models.Tags +import org.permanent.permanent.network.models.ArchivesV2Response import org.permanent.permanent.network.models.FolderChildrenResponse import org.permanent.permanent.network.models.FolderResponse import org.permanent.permanent.network.models.RecordResponse @@ -49,6 +51,16 @@ interface StelaAccountService { @Query("pageSize") pageSize: Int = 99999999 ): Call + // The caller's archive memberships; items[].rootFolderId replaces the V1 getRoot + // bootstrap (VSP-1788). Repeated (unbracketed) callerMembershipRole params — the + // form the server's own nextPage emits; Retrofit renders a List @Query that way. + @Headers("Request-Version: 2") + @GET("api/v2/archives") + fun getArchives( + @Query("callerMembershipRole") roles: List = ALL_MEMBERSHIP_ROLES, + @Query("pageSize") pageSize: Int = ARCHIVES_PAGE_SIZE + ): Call + // Bearer-token flavor for browsing the user's own archive (VSP-1778), on the // documented plural route (the singular form above is a deprecated alias). @Headers("Request-Version: 2") @@ -90,5 +102,12 @@ interface StelaAccountService { // single page (cursor pagination deferred — nextCursor is non-null even on a // complete page, so loop termination is unreliable). Same value iOS ships. const val MAX_CHILDREN_PAGE_SIZE = 99999999 + + // The archives search requires a query or a role; passing every role resolves + // the selected archive whatever the caller's role on it. One page sized above + // any realistic membership count — more archives than that falls back to the + // V1 getRoot bootstrap. Same values iOS ships. + val ALL_MEMBERSHIP_ROLES = AccessRole.values().map { it.lowerCase() } + const val ARCHIVES_PAGE_SIZE = 100 } } \ No newline at end of file diff --git a/app/src/main/java/org/permanent/permanent/network/models/ArchivesV2Response.kt b/app/src/main/java/org/permanent/permanent/network/models/ArchivesV2Response.kt new file mode 100644 index 00000000..5c7e0113 --- /dev/null +++ b/app/src/main/java/org/permanent/permanent/network/models/ArchivesV2Response.kt @@ -0,0 +1,14 @@ +package org.permanent.permanent.network.models + +data class ArchivesV2Response( + val items: List? +) + +// Only the fields consumed are modeled (Moshi drops the rest). Both arrive as +// numeric strings; rootFolderId is the archive's top-level folder, whose children +// are the section roots (My Files / Public) — one /children hop from the folder +// navigation lands in (VSP-1788). +data class ArchiveV2DTO( + val archiveNbr: String?, + val rootFolderId: String? +) diff --git a/app/src/main/java/org/permanent/permanent/repositories/FileRepositoryImpl.kt b/app/src/main/java/org/permanent/permanent/repositories/FileRepositoryImpl.kt index 5139c23a..46cc931f 100644 --- a/app/src/main/java/org/permanent/permanent/repositories/FileRepositoryImpl.kt +++ b/app/src/main/java/org/permanent/permanent/repositories/FileRepositoryImpl.kt @@ -4,6 +4,8 @@ import android.content.Context import android.content.SharedPreferences import okhttp3.MediaType import okhttp3.ResponseBody +import org.permanent.permanent.BuildConfig +import org.permanent.permanent.Constants import org.permanent.permanent.R import org.permanent.permanent.mapper.toRecordV2 import org.permanent.permanent.models.NavigationFolderIdentifier @@ -13,6 +15,7 @@ import org.permanent.permanent.models.Tag import org.permanent.permanent.network.IRecordListener import org.permanent.permanent.network.IResponseListener import org.permanent.permanent.network.NetworkClient +import org.permanent.permanent.network.models.ArchivesV2Response import org.permanent.permanent.network.models.FileData import org.permanent.permanent.network.models.FolderChildrenResponse import org.permanent.permanent.network.models.GetPresignedUrlResponse @@ -42,13 +45,7 @@ class FileRepositoryImpl(val context: Context) : IFileRepository { NetworkClient.instance().getRoot().enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val responseVO = response.body() - val publicRecord = responseVO?.getPublicRecord() - prefsHelper.savePublicRecordInfo( - publicRecord?.folderId, - publicRecord?.folderLinkId, - publicRecord?.archiveNr, - publicRecord?.thumbnail256 ?: publicRecord?.thumbURL2000 - ) + savePublicRecordInfo(responseVO?.getPublicRecord()) val myFilesRecord = responseVO?.getMyFilesRecord() if (myFilesRecord != null) { @@ -67,6 +64,106 @@ class FileRepositoryImpl(val context: Context) : IFileRepository { }) } + // Stela V2 replacement for the getRoot bootstrap (VSP-1788): the archives search + // carries the archive's rootFolderId, whose children are the section roots — so + // My Files is two V2 reads away. Any anomaly reports onFailed so the caller can + // run the V1 getRoot failsafe. isStale short-circuits a superseded load (rapid + // archive switch) before the second read and the prefs write. + override fun getMyFilesRecordV2(isStale: () -> Boolean, listener: IRecordListener) { + val currentArchiveNr = prefsHelper.getCurrentArchiveNr() + if (currentArchiveNr.isNullOrEmpty()) { + listener.onFailed(null) + return + } + NetworkClient.instance().getArchivesV2().enqueue(object : Callback { + + override fun onResponse( + call: Call, + response: Response + ) { + if (isStale()) { + listener.onFailed(null) + return + } + // The session holds archiveId as an Int, so archiveNbr is the stable + // string-to-string key (same matching iOS ships). + val rootFolderId = response.body()?.items + ?.find { it.archiveNbr == currentArchiveNr } + ?.rootFolderId?.toIntOrNull()?.takeIf { it > 0 } + if (rootFolderId == null) { + // The message only feeds a DEBUG log in the caller; the V2 root + // path always falls back to V1 instead of surfacing it. + listener.onFailed( + if (BuildConfig.DEBUG) response.errorBody()?.string() else null + ) + return + } + getMyFilesRecordFromSectionRoots(rootFolderId, isStale, listener) + } + + override fun onFailure(call: Call, t: Throwable) { + listener.onFailed(t.message) + } + }) + } + + private fun getMyFilesRecordFromSectionRoots( + rootFolderId: Int, isStale: () -> Boolean, listener: IRecordListener + ) { + // Reuses the children fetch so the section roots pass the same contract-failure + // and corrupt-item rules as every listed folder. + getChildRecordsOfV2(rootFolderId, object : IFolderChildrenListener { + + override fun onSuccess(records: List) { + if (isStale()) { + listener.onFailed(null) + return + } + // Same side effect as the V1 getRoot path: these prefs are the sole + // source for publish-to-Public and the profile banner (null fields + // are skipped, so a missing child leaves them untouched). + savePublicRecordInfo( + findSectionRoot( + records, Constants.PUBLIC_FILES_FOLDER_TYPE, Constants.PUBLIC_FILES_FOLDER + ) + ) + val myFilesRecord = findSectionRoot( + records, Constants.MY_FILES_FOLDER_TYPE, Constants.MY_FILES_FOLDER + ) + if (myFilesRecord != null) { + listener.onSuccess(myFilesRecord) + } else { + listener.onFailed(null) + } + } + + override fun onFailed(error: String?) { + listener.onFailed(error) + } + }) + } + + // Type-first with a display-name safety net (iOS parity), and folders only — a + // record named like a section must not be picked. Live staging sends the short + // types "private-root"/"public-root" (captured 2026-08-20), normalized by the + // mapper to the canonical dotted-hyphen form. + private fun findSectionRoot( + records: List, sectionType: String, fallbackDisplayName: String + ): Record? { + val folders = records.filter { it.type == RecordType.FOLDER } + return folders.find { it.backendType == sectionType } + ?: folders.find { it.displayName == fallbackDisplayName } + } + + private fun savePublicRecordInfo(publicRecord: Record?) { + prefsHelper.savePublicRecordInfo( + publicRecord?.folderId, + publicRecord?.folderLinkId, + publicRecord?.archiveNr, + publicRecord?.thumbnail256 ?: publicRecord?.thumbURL2000 + ) + } + override fun getPublicRoot(archiveNr: String?, listener: IRecordListener) { NetworkClient.instance().getPublicRootForArchive(archiveNr) .enqueue(object : Callback { diff --git a/app/src/main/java/org/permanent/permanent/repositories/IFileRepository.kt b/app/src/main/java/org/permanent/permanent/repositories/IFileRepository.kt index f96bd052..15f601c3 100644 --- a/app/src/main/java/org/permanent/permanent/repositories/IFileRepository.kt +++ b/app/src/main/java/org/permanent/permanent/repositories/IFileRepository.kt @@ -23,6 +23,8 @@ import java.util.Date interface IFileRepository { fun getMyFilesRecord(listener: IRecordListener) + fun getMyFilesRecordV2(isStale: () -> Boolean, listener: IRecordListener) + fun getPublicRoot(archiveNr: String?, listener: IRecordListener) fun getChildRecordsOf( diff --git a/app/src/main/java/org/permanent/permanent/ui/PreferencesHelper.kt b/app/src/main/java/org/permanent/permanent/ui/PreferencesHelper.kt index 6c94d954..1ae13c13 100644 --- a/app/src/main/java/org/permanent/permanent/ui/PreferencesHelper.kt +++ b/app/src/main/java/org/permanent/permanent/ui/PreferencesHelper.kt @@ -253,29 +253,12 @@ class PreferencesHelper(private val sharedPreferences: SharedPreferences) { fun savePublicRecordInfo( folderId: Int?, folderLinkId: Int?, archiveNr: String?, thumbURL2000: String? ) { - folderId?.let { - with(sharedPreferences.edit()) { - putInt(PREFS_PUBLIC_RECORD_FOLDER_ID, it) - apply() - } - } - folderLinkId?.let { - with(sharedPreferences.edit()) { - putInt(PREFS_PUBLIC_RECORD_FOLDER_LINK_ID, it) - apply() - } - } - archiveNr?.let { - with(sharedPreferences.edit()) { - putString(PREFS_PUBLIC_RECORD_ARCHIVE_NR, it) - apply() - } - } - thumbURL2000?.let { - with(sharedPreferences.edit()) { - putString(PREFS_PUBLIC_RECORD_THUMB_URL_2000, it) - apply() - } + with(sharedPreferences.edit()) { + folderId?.let { putInt(PREFS_PUBLIC_RECORD_FOLDER_ID, it) } + folderLinkId?.let { putInt(PREFS_PUBLIC_RECORD_FOLDER_LINK_ID, it) } + archiveNr?.let { putString(PREFS_PUBLIC_RECORD_ARCHIVE_NR, it) } + thumbURL2000?.let { putString(PREFS_PUBLIC_RECORD_THUMB_URL_2000, it) } + apply() } } diff --git a/app/src/main/java/org/permanent/permanent/viewmodels/MyFilesViewModel.kt b/app/src/main/java/org/permanent/permanent/viewmodels/MyFilesViewModel.kt index f1d902a4..69e3ad1e 100644 --- a/app/src/main/java/org/permanent/permanent/viewmodels/MyFilesViewModel.kt +++ b/app/src/main/java/org/permanent/permanent/viewmodels/MyFilesViewModel.kt @@ -67,6 +67,9 @@ open class MyFilesViewModel(application: Application) : SelectionViewModel(appli // Monotonic id of the newest V2 children fetch; only the newest may commit and // superseded fetches complete quietly (see loadFilesOfV2). Touched on main only. private var childrenFetchGeneration = 0 + + // Same guard for root loads (see loadRootFilesV2). Touched on main only. + private var rootLoadGeneration = 0 private val isRoot = MutableLiveData(true) private val sortName: MutableLiveData = MutableLiveData(SortType.NAME_ASCENDING.toUIString()) @@ -133,6 +136,14 @@ open class MyFilesViewModel(application: Application) : SelectionViewModel(appli } open fun loadRootFiles() { + if (FeatureFlags.useStelaMigration) { + loadRootFilesV2() + } else { + loadRootFilesV1() + } + } + + private fun loadRootFilesV1() { swipeRefreshLayout.isRefreshing = true fileRepository.getMyFilesRecord(object : IRecordListener { override fun onSuccess(record: Record) { @@ -149,6 +160,51 @@ open class MyFilesViewModel(application: Application) : SelectionViewModel(appli }) } + // Stela V2 root discovery (VSP-1788), with V1 getRoot as the automatic failsafe + // (whose record carries folderId, so drill-in stays on V2). Only the newest root + // load may commit: the archive-changed observer re-fires this on a live ViewModel, + // so an out-of-order response — or its V1 failsafe — could otherwise paint the + // previous archive's root. + private fun loadRootFilesV2() { + swipeRefreshLayout.isRefreshing = true + val generation = ++rootLoadGeneration + val isStale = { generation != rootLoadGeneration } + fileRepository.getMyFilesRecordV2(isStale, rootLoadListener(generation) { error -> + if (BuildConfig.DEBUG) Log.d( + TAG, "V2 root resolution failed ($error), falling back to V1 getRoot" + ) + fileRepository.getMyFilesRecord(rootLoadListener(generation) { fallbackError -> + swipeRefreshLayout.isRefreshing = false + fallbackError?.let { showMessage.value = it } + }) + }) + } + + // Commits only while still the newest root load; a superseded one must neither + // commit nor run its failsafe out of order. + private fun rootLoadListener( + generation: Int, onFailure: (String?) -> Unit + ) = object : IRecordListener { + override fun onSuccess(record: Record) { + if (generation == rootLoadGeneration) commitRootRecord(record) + } + + override fun onFailed(error: String?) { + if (generation == rootLoadGeneration) onFailure(error) + } + } + + private fun commitRootRecord(record: Record) { + swipeRefreshLayout.isRefreshing = false + // Reset instead of push: a root load on a surviving ViewModel (archive switch + // from the Save-to-Permanent sheet) must not leave the previous archive's root + // reachable through back navigation. + folderPathStack.clear() + folderPathStack.push(record) + loadFilesAndUploadsOf(record, forwardNavigation = true) + loadEnqueuedDownloads(lifecycleOwner) + } + fun setExistsDownloads(existsDownloads: MutableLiveData) { this.existsDownloads = existsDownloads } diff --git a/docs/stela/android-stela-status-board.html b/docs/stela/android-stela-status-board.html index 8496a3fb..ad1ad6e2 100644 --- a/docs/stela/android-stela-status-board.html +++ b/docs/stela/android-stela-status-board.html @@ -109,7 +109,7 @@

Permanent Android · Stela backend migration

Android Stela Migration — Status Board

Flag: compile-time FeatureFlags.useStelaMigration (staging flavor ON — any build type / production OFF, per iOS PRs #575/#580) · - Updated 2026-08-14 · edit by hand, one item per surface. + Updated 2026-08-19 · edit by hand, one item per surface. Note: iOS currently ships its flag OFF in every build (1.16.0 deferred the epic; re-enable with --forceStelaNavigation on debug/staging) — force it on for cross-platform QA.

@@ -152,6 +152,11 @@

Android Stela Migration — Status Board

GET /v2/folders/{id}/children

VSP-1802 · tap a folder shared with you (foreign archive) → V2 listing, same funnel/mapper/failsafe/supersede as by-me · gate is useStelaMigration && folderId > 0, no ownership condition — bearer-only V2 reads resolve share membership server-side (verified in the stela source; iOS shipped the same in PR #574) · per-item permissions decoded from the payload's top-level accessRole (share-aware), null clamped to VIEWER · caveat: unauthorized children are silently omitted from a 200 listing (reads never 401 on auth) · the shares root listing stays V1 getShares (see backend column).

+
  • +
    Root discovery — My FilesBehind flag
    + GET /v2/archives → rootFolderId → /children → private-root child +

    VSP-1788 · replaces the V1 getRoot bootstrap (iOS's VSP-1787): match items[].archiveNbr to the session archive, list the root's children, pick the private-root child (live-verified spelling; display-name fallback) · nothing cached — re-resolved per root load, newest-only commit + path-stack reset on archive switch · V1 getRoot stays the failsafe and the flag-off path · the same children refill the public-record prefs (publish/banner) · live-verified 2026-08-19: archive/change carries no root id.

    +
  • Share-link preview gridLive
    GET /v2/folder/{id}/children (share token) @@ -190,9 +195,9 @@

    Android Stela Migration — Status Board

    • -
      Root discovery (My Files + Public Files)Not started
      - GET /v2/archives → rootFolderId → /children → section-root child -

      VSP-1778/VSP-1808 keep V1 getRoot/getPublicRoot as bootstrap; the archives chain is iOS's VSP-1787 (PR #574), which resolves both section roots — one future ticket covers both.

      +
      Root discovery (Public Files)Not started
      + GET /v2/archives → rootFolderId → /children → public-root child +

      VSP-1808 keeps V1 getPublicRoot as the own-archive bootstrap; VSP-1788 shipped the archives chain for My Files and its resolver already picks the public-root child (prefs side effect) — this ticket is the PublicFilesViewModel hookup. Foreign archives (gallery) can never migrate: /v2/archives is membership-scoped.

    • Record detail · viewer readsNot started
      diff --git a/docs/stela/folders-children-contract.md b/docs/stela/folders-children-contract.md index 964b39b7..5f71198f 100644 --- a/docs/stela/folders-children-contract.md +++ b/docs/stela/folders-children-contract.md @@ -5,8 +5,63 @@ updated 2026-07-30 against PRs #574/#575/#576/#580, and 2026-08-14 against iOS D `808aea6` + the stela source itself for VSP-1802), live production captures (2026-07-22), and the published stela docs — in that order of authority. Written for VSP-1778 (Private Files navigation), extended for VSP-1808 (Public Files), VSP-1810 (Public Gallery), -VSP-1806 (Search drill-in), VSP-1803 (Shared By Me drill-in) and VSP-1802 (Shared With Me -drill-in); reuse for future Stela tickets instead of re-deriving. +VSP-1806 (Search drill-in), VSP-1803 (Shared By Me drill-in), VSP-1802 (Shared With Me +drill-in) and VSP-1788 (root resolution); reuse for future Stela tickets instead of +re-deriving. + +## Root resolution — My Files (VSP-1788) + +Replaces the V1 `folder/getroot` bootstrap: with the flag on, the private root is +discovered from Stela reads only. Verified against iOS Development (`ArchiveV2Endpoint`, +`MyFilesViewModel.resolveSectionRootTargetV2`, wire shape pinned in +`FilesViewModelTests.swift:2224`) and a live staging capture (2026-08-19). + +- **The chain (2 reads, then the existing listing):** + `GET api/v2/archives?callerMembershipRole=owner&…&callerMembershipRole=viewer&pageSize=100` + (header `Request-Version: 2`, bearer auth) → match `items[].archiveNbr` string-to-string + against the session's current archive (`PreferencesHelper.getCurrentArchiveNr()`) → + `items[].rootFolderId` (numeric string, a **V2 folderId**, directly usable) → + `GET api/v2/folders/{rootFolderId}/children` → the children are the **section roots**, + short types `app-root` / `private-root` / `public-root` (live capture 2026-08-20 — + the iOS spellings, NOT the `root.private` form previously assumed from the field-notes + table); pick the private root (`type.folder.private-root` after mapper normalization, + underscore spelling tolerated like iOS, display-name `"My Files"` as the safety net, + folders only) → that child seeds the existing V2 navigation. +- **Query form:** repeated unbracketed `callerMembershipRole=` params (the form the + server's own `nextPage` emits — NOT `[]`-bracketed, NOT comma-joined). All 6 roles are + passed because the endpoint requires a query or a role. Both pinned by iOS unit tests. +- **Live archives payload (staging, 2026-08-20):** items carry, beyond the 7 fields + Android decodes, `description`, `public`, `publicAt`, `allowPublicDownload`, + `thumbnailUrls`, `owner`, `payerAccountId`, `milestoneSortOrder`, `createdAt`, + `updatedAt`, `totalPages` — ignored by Moshi. Quirk: `pagination.nextPage` points at + `/api/v2/archive` (singular) — irrelevant while pagination stays unused, but don't + trust that URL if cursor paging is ever adopted. +- **`archive/change` does NOT carry a root id — live-verified 2026-08-19.** The ticket + hypothesized it would; the raw staging response has `ChildFolderVOs`/`FolderSizeVOs`/ + `RecordVOs`/`ItemVOs` all `[]` and no root-folder scalar (fields beyond our Kotlin + model: `milestoneSortOrder`, `vaultKey`, `allowPublicDownload`, `payerAccountId`, + `view`, `viewProperty`, `imageRatio`, `publicDT`, `thumbDT`). The web-app likewise calls + `/folder/getRoot` right after `/archive/change`. The archives search is the only source. +- **Refresh semantics: nothing is cached** (iOS parity). The 2-call discovery re-runs on + every root load — My Files screen open and the archive-changed observer. Only the newest + root load may commit (`rootLoadGeneration` in `MyFilesViewModel`), and a commit resets + `folderPathStack` so a switch on a live ViewModel can't leak the previous archive's root + into back navigation. +- **V1 failsafe unchanged:** any anomaly (archives fetch fails, no archiveNbr match — e.g. + >100 memberships, missing/non-positive `rootFolderId`, children contract failure, no + section-root child) falls back to V1 `getRoot`, whose record still carries `folderId` so + drill-in stays on V2. Flag off runs the V1 path byte-for-byte. A discovery 401 can never + logout (`treatStelaUnauthorizedAsSessionExpiry` stays off). +- **Hidden second job preserved:** V1 `getRoot` was the sole writer of the + `PREFS_PUBLIC_RECORD_*` keys (publish-to-Public + profile banner). The V2 path finds the + public section root (`public-root`, fallback name `"Public"`) among the same archive-root + children and writes the same prefs through the same helper, which skips null fields — so + a missing child leaves working prefs untouched on both paths. A superseded root load + (rapid archive switch) is short-circuited in the repository before the prefs write, so a + stale response can't stamp the outgoing archive's public root. +- **`getPublicRoot` is out of scope and stays V1** — own-archive Public Files could ride + this same resolver in a future ticket; foreign archives can never use it (`/archives` is + membership-scoped). ## Shared With Me → folder drill-in (VSP-1802) @@ -315,7 +370,7 @@ merged iOS code ignores it and discriminates by id presence; Android does the sa | `archiveNumber` | Non-numeric string (e.g. `"0001-test"`) — **never** int-convert. Also available nested as `archive.archiveNumber`. | | `displayName` | Same as V1. | | `displayDate` / `displayTimestamp` | Split by kind: **records → `displayDate`**, **folders → `displayTimestamp`** (ISO-8601). Normalize with the existing `replace("T", " ")`. `fileCreatedAt`, `createdAt`, `updatedAt` also present. | -| `type` | **Records keep dotted legacy forms** (`type.record.image`); **folders answer new short forms** (`private`, `root.private`…). Android normalizes folders back to `type.folder.` in the mapper so downstream consumers see V1-shaped values. Confirmed intentional server-side (`prettifyFolderType` in stela). | +| `type` | **Records keep dotted legacy forms** (`type.record.image`); **folders answer new short forms** (`private`; section roots are `private-root`/`public-root`/`app-root` — live-verified 2026-08-20, the earlier `root.private` example here was wrong). Android normalizes folders back to `type.folder.` in the mapper so downstream consumers see V1-shaped values. Confirmed intentional server-side (`prettifyFolderType` in stela). | | `status` | Same split: records dotted (`status.generic.ok`), folders short (`ok`, `copying`, `moving`). `copying`/`moving` → item non-tappable (`isProcessing`). There is **no `thumbStatus`** — a file with no thumbnails yet is treated as still processing (same derivation as the shares screen's `Record(ItemVO)`). | | `parentFolder { id, folderLinkId }` | **Folders nest** parent info here; **records send it flat** (`parentFolderId`/`parentFolderLinkId`). Resolve flat-then-nested. Added in stela PR #773. | | `paths { names, folderLinkIds, archiveNumbers }` | Full breadcrumb trail (stela PR #773). Android doesn't consume it (breadcrumbs are the client-side `folderPathStack`). | @@ -488,5 +543,7 @@ on V1. | iOS status board: Public Files nav = "VSP-1809, shipped in PR #574" | Merged code: drill-in shipped in **PR #573** (inheritance); PR #574 is **VSP-1787** (V2 root discovery). Board lags/mislabels | | iOS artifacts: foreign public browsing "confirmed solvable, not yet wired" | Superseded — **PR #576** (merged 2026-07-29) wired it (drill-in V2, root stays V1 `getPublicRoot`) | | iOS artifacts + old gap 1: "V2 children carry no per-child caller `accessRole`" | Was true when written — the field was delivered 2026-08-13 by PER-10716 (stela PR #835). Android decodes it since VSP-1802; iOS derives child roles by inheritance | -| PER-10716 folders-page test: `accessRole` = short `"viewer"` | The children wire sends the **dotted** form `access.role.viewer` (live capture 2026-08-14); `AccessRole.fromStelaBackendValue`'s dot-tolerance covers both | +| PER-10716 folders-page test: `accessRole` = short `"viewer"` | Resolved 2026-08-20: stela **main** now emits the short form for the caller-level `accessRole` on folder/record/children responses (`resolveAccessRole` returns `ArchiveMembershipRole` — source-verified), but **staging still sends dotted** (live capture 2026-08-20) — the deploy is pending. Web adapted ahead of it (web-app PR #1139). Android is safe both ways: `fromStelaBackendValue`'s dot-tolerance parses both forms, and **released** Android builds don't decode the field at all (master's ItemDTO/RecordResponse/FolderResponse have no caller accessRole; Moshi drops unknown keys). `pendingShares[].accessRole` and `shares[].accessRole` are raw DB passthrough (dotted) — NOT routed through `resolveAccessRole`, so they don't change with this deploy; their parsers clamp unknowns to VIEWER anyway | | Access map: "share-membership foreign content ✗ on V2" | Wrong for READS — inferred from #576's PATCH (a write). Reads authorize via the `access` table, descendants included; corrected 2026-08-14 | +| VSP-1788 ticket: "the call to change archives should return the rootFolderId" | It does not — live staging capture 2026-08-19 shows no root identity anywhere in the `archive/change` response; the source is `GET /v2/archives` `items[].rootFolderId` | +| Field-notes short-form example `root.private` for section roots | Wrong — live staging (2026-08-20) sends `private-root`/`public-root`/`app-root`, matching iOS's `FileType.fromV2` spellings. Android matches `type.folder.private-root` post-normalization (underscore tolerated), display name as safety net |