diff --git a/app/build.gradle b/app/build.gradle index 6381c81b..c0b362c5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,6 +42,7 @@ android { productFlavors { production { applicationId "org.permanent.PermanentArchive" + buildConfigField "boolean", "STELA_MIGRATION_DEFAULT", "false" buildConfigField "String", "BASE_URL", "\"https://app.permanent.org/\"" buildConfigField "String", "BASE_API_URL", "\"https://app.permanent.org/api/\"" buildConfigField "String", "BASE_API_URL_STELA", "\"https://api.permanent.org/\"" @@ -54,6 +55,7 @@ android { } staging { applicationId "org.permanent.permanent.staging" + buildConfigField "boolean", "STELA_MIGRATION_DEFAULT", "true" buildConfigField "String", "BASE_URL", "\"https://app.staging.permanent.org/\"" buildConfigField "String", "BASE_API_URL", "\"https://app.staging.permanent.org/api/\"" buildConfigField "String", "BASE_API_URL_STELA", "\"https://api.staging.permanent.org/\"" diff --git a/app/src/main/java/org/permanent/permanent/FeatureFlags.kt b/app/src/main/java/org/permanent/permanent/FeatureFlags.kt index 8a5078f3..bc99bb05 100644 --- a/app/src/main/java/org/permanent/permanent/FeatureFlags.kt +++ b/app/src/main/java/org/permanent/permanent/FeatureFlags.kt @@ -7,13 +7,14 @@ package org.permanent.permanent */ object FeatureFlags { /** - * Master switch for the Stela V2 migration: Private Files navigation now - * (VSP-1778), records, folder creation etc. in future tickets — one flag for - * the whole migration. ON in debug builds so the V2 path gets exercised; - * OFF in every release build until flipped for rollout. V1 remains an - * automatic failsafe on every gated path, so OFF is always safe. - * `var` so tests or a future debug toggle can pin it; nothing mutates it - * in production code. + * Master switch for the Stela V2 migration — one flag for the whole migration + * (Private Files VSP-1778, Public Files VSP-1808, more tickets to come). The + * default is declared per flavor (STELA_MIGRATION_DEFAULT in app/build.gradle) + * so the environment fact lives with the flavor that owns it: a productionDebug + * build must never send V2 calls to the production API (the leak iOS fixed in + * their PRs #575/#580). V1 remains an automatic failsafe on every gated path, + * so OFF is always safe. `var` so tests or a future debug toggle can pin it; + * nothing mutates it in production code. */ - var useStelaMigration: Boolean = BuildConfig.DEBUG + var useStelaMigration: Boolean = BuildConfig.STELA_MIGRATION_DEFAULT } 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 3fb917b7..a677508f 100644 --- a/app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt +++ b/app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt @@ -31,9 +31,10 @@ fun ItemDTO.toRecord(): Record { } /** - * Maps a V2 /folders/{id}/children item for authenticated Private Files browsing - * (VSP-1778). Kept separate from [toRecord] so the share-preview path stays - * byte-identical while V2 navigation is gated behind FeatureFlags.useStelaMigration. + * Maps a V2 /folders/{id}/children item for authenticated owner-workspace browsing — + * Private Files (VSP-1778) and Public Files (VSP-1808). Kept separate from [toRecord] + * so the share-preview path stays byte-identical while V2 navigation is gated behind + * FeatureFlags.useStelaMigration. */ fun ItemDTO.toRecordV2(): Record { val isFolder = folderId != null @@ -54,13 +55,13 @@ fun ItemDTO.toRecordV2(): Record { rec.type = if (isFolder) RecordType.FOLDER else RecordType.FILE rec.backendType = normalizedBackendType(isFolder) rec.size = size ?: -1L - // thumbnailUrls."256" is the Archivematica access copy — blank for HEIC — so only - // the flat thumbnail256 counts (it appears once processing finishes); the UI then - // falls through to the .thumb.wNNN renditions, read from the NESTED thumbnailUrls — - // records also send flat thumbUrl* duplicates, folders don't, so nested covers both. - // The wire may send empty strings instead of null — treat as absent. + // .thumb.wNNN renditions are read from the NESTED thumbnailUrls (folders send no + // flat thumbUrl*; records duplicate them flat), and only the flat thumbnail256 + // fills the 256 slot. The nested "256" is the Archivematica access copy — blank + // for HEIC, but the only thumbnail a Stela V2 record copy has (no renditions, + // backend gap) — so it serves as a HEIC-guarded LAST resort in the 200 slot. rec.thumbnail256 = thumbnail256.orNullIfEmpty() - rec.thumbURL200 = thumbnailUrls?.url200.orNullIfEmpty() + rec.thumbURL200 = thumbnailUrls?.url200.orNullIfEmpty() ?: accessCopyThumb256() rec.thumbURL2000 = thumbnailUrls?.url2000.orNullIfEmpty() rec.isProcessing = when (status?.substringAfterLast('.')) { "copying", "moving" -> true @@ -73,6 +74,20 @@ fun ItemDTO.toRecordV2(): Record { return rec } +private fun ItemDTO.accessCopyThumb256(): String? = + thumbnailUrls?.url256.orNullIfEmpty()?.takeUnless { isHeicOriginal() } + +private fun ItemDTO.isHeicOriginal(): Boolean { + if (files.orEmpty().any { + "original" in it.format.orEmpty() && + (it.type.orEmpty().contains("heic", ignoreCase = true) || + it.type.orEmpty().contains("heif", ignoreCase = true)) + } + ) return true + val name = (uploadFileName ?: downloadName).orEmpty().lowercase() + return name.endsWith(".heic") || name.endsWith(".heif") +} + // Folders answer with new short type forms ("private", "root.private"…) 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. diff --git a/app/src/main/java/org/permanent/permanent/network/models/FileDTO.kt b/app/src/main/java/org/permanent/permanent/network/models/FileDTO.kt new file mode 100644 index 00000000..ee1ee0a0 --- /dev/null +++ b/app/src/main/java/org/permanent/permanent/network/models/FileDTO.kt @@ -0,0 +1,9 @@ +package org.permanent.permanent.network.models + +// Per-file metadata on a V2 children record item. Only what the HEIC guard in +// ItemMapper needs: the original file is the entry whose format contains +// "original"; its type tells the source format (e.g. "type.file.image.heic"). +data class FileDTO( + val format: String?, + val type: String?, +) diff --git a/app/src/main/java/org/permanent/permanent/network/models/ItemDTO.kt b/app/src/main/java/org/permanent/permanent/network/models/ItemDTO.kt index d55dc10b..9dc72b5e 100644 --- a/app/src/main/java/org/permanent/permanent/network/models/ItemDTO.kt +++ b/app/src/main/java/org/permanent/permanent/network/models/ItemDTO.kt @@ -25,4 +25,9 @@ data class ItemDTO( val thumbnailUrls: ThumbnailUrlsDTO? = null, val shares: List? = null, val pendingShares: List? = null, + // HEIC detection for the access-copy thumbnail fallback in ItemMapper; the + // file names are the fallback signal when files[] is absent. + val uploadFileName: String? = null, + val downloadName: String? = null, + val files: List? = null, ) diff --git a/app/src/main/java/org/permanent/permanent/network/models/ThumbnailUrlsDTO.kt b/app/src/main/java/org/permanent/permanent/network/models/ThumbnailUrlsDTO.kt index 52eb9972..a84bb94a 100644 --- a/app/src/main/java/org/permanent/permanent/network/models/ThumbnailUrlsDTO.kt +++ b/app/src/main/java/org/permanent/permanent/network/models/ThumbnailUrlsDTO.kt @@ -3,8 +3,9 @@ package org.permanent.permanent.network.models import com.squareup.moshi.Json // Nested thumbnail renditions keyed by width. "256" is the Archivematica access -// copy — blank for HEIC — and must never be used as a thumbnail source; only the -// item's flat thumbnail256 counts (see ItemMapper.toRecordV2). +// copy — blank for HEIC — used only as a HEIC-guarded last resort for records +// with no renditions; the 256 slot itself takes only the item's flat +// thumbnail256 (see ItemMapper.toRecordV2 / accessCopyThumb256). data class ThumbnailUrlsDTO( @field:Json(name = "200") val url200: String?, @field:Json(name = "256") val url256: String?, 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 ca0ddaa3..6cd84ce5 100644 --- a/app/src/main/java/org/permanent/permanent/viewmodels/MyFilesViewModel.kt +++ b/app/src/main/java/org/permanent/permanent/viewmodels/MyFilesViewModel.kt @@ -64,11 +64,6 @@ open class MyFilesViewModel(application: Application) : SelectionViewModel(appli private val folderName = MutableLiveData(Constants.PRIVATE_FILES) private var refreshJob: Job? = null - // Private Files routes folder navigation through the Stela V2 children endpoint - // when the migration flag is on, with V1 as an automatic failsafe. - // PublicFilesViewModel overrides this to false and stays on V1 (VSP-1778). - protected open val useStelaMigration: Boolean get() = FeatureFlags.useStelaMigration - // 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 @@ -103,7 +98,7 @@ open class MyFilesViewModel(application: Application) : SelectionViewModel(appli protected lateinit var swipeRefreshLayout: SwipeRefreshLayout private lateinit var fragmentManager: FragmentManager protected lateinit var lifecycleOwner: LifecycleOwner - private val prefsHelper = PreferencesHelper( + protected val prefsHelper = PreferencesHelper( appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) ) @@ -178,8 +173,11 @@ open class MyFilesViewModel(application: Application) : SelectionViewModel(appli val folderLinkId = folder?.getFolderIdentifier()?.folderLinkId if (archiveNr != null && folderLinkId != null) { swipeRefreshLayout.isRefreshing = true + // Private Files (VSP-1778) and Public Files (via PublicFilesViewModel, + // VSP-1808) take the Stela V2 children endpoint when the migration flag + // is on, with V1 as an automatic failsafe. val folderId = folder.getFolderIdentifier()?.folderId - if (useStelaMigration && folderId != null && folderId > 0) { + if (FeatureFlags.useStelaMigration && folderId != null && folderId > 0) { loadFilesOfV2(folder, sortType, forwardNavigation) } else { loadFilesOfV1(folder, sortType) diff --git a/app/src/main/java/org/permanent/permanent/viewmodels/PublicFilesViewModel.kt b/app/src/main/java/org/permanent/permanent/viewmodels/PublicFilesViewModel.kt index d549e4f2..b2816698 100644 --- a/app/src/main/java/org/permanent/permanent/viewmodels/PublicFilesViewModel.kt +++ b/app/src/main/java/org/permanent/permanent/viewmodels/PublicFilesViewModel.kt @@ -1,32 +1,17 @@ package org.permanent.permanent.viewmodels import android.app.Application -import android.content.Context import androidx.lifecycle.MutableLiveData import org.permanent.permanent.Constants -import org.permanent.permanent.PermanentApplication import org.permanent.permanent.models.Record import org.permanent.permanent.network.IRecordListener -import org.permanent.permanent.ui.PREFS_NAME -import org.permanent.permanent.ui.PreferencesHelper class PublicFilesViewModel(application: Application) : MyFilesViewModel(application) { - // Public Files stays on the V1 navigation path — only Private Files is in the - // Stela V2 migration scope for now (VSP-1778). - override val useStelaMigration: Boolean get() = false - - private val prefsHelper = PreferencesHelper( - application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - ) private val onRootFolderReady = SingleLiveEvent() init { getFolderName().value = Constants.PUBLIC_FILES - - PermanentApplication.instance.relocateData?.let { - setRelocationMode(it) - } } override fun loadRootFiles() { @@ -35,7 +20,7 @@ class PublicFilesViewModel(application: Application) : MyFilesViewModel(applicat override fun onSuccess(record: Record) { swipeRefreshLayout.isRefreshing = false folderPathStack.push(record) - loadFilesAndUploadsOf(record) + loadFilesAndUploadsOf(record, forwardNavigation = true) loadEnqueuedDownloads(lifecycleOwner) onRootFolderReady.call() } diff --git a/docs/stela/android-stela-status-board.html b/docs/stela/android-stela-status-board.html index bb74adde..3f650597 100644 --- a/docs/stela/android-stela-status-board.html +++ b/docs/stela/android-stela-status-board.html @@ -108,8 +108,8 @@

Permanent Android · Stela backend migration

Android Stela Migration — Status Board

-

Flag: compile-time FeatureFlags.useStelaMigration (debug ON / release OFF) · - Updated 2026-07-24 · edit by hand, one item per surface.

+

Flag: compile-time FeatureFlags.useStelaMigration (staging flavor ON — any build type / production OFF, per iOS PRs #575/#580) · + Updated 2026-07-30 · edit by hand, one item per surface.

@@ -126,6 +126,11 @@

Android Stela Migration — Status Board

GET /v2/folders/{id}/children

VSP-1778 · replaces navigateMin + getLeanItems · single fetch pageSize=99999999, local sort, V1 failsafe, supersede policy. Known gap: pending badge undercounts (see backend column).

+
  • +
    Public Files navigation & listingBehind flag
    + GET /v2/folders/{id}/children +

    VSP-1808 · same seam as Private Files (inherited via PublicFilesViewModel), same invariants · root stays V1 getPublicRoot · thumbnail rule refined (access copy = HEIC-guarded last resort, iOS PR #575) · pending-badge gap doesn't apply (badge not rendered here).

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

    Android Stela Migration — Status Board

    • -
      My Files root discoveryNot started
      - GET /v2/archives → rootFolderId → /children -

      VSP-1778 keeps V1 getRoot as bootstrap (same as iOS PR #573); the archives chain is iOS's VSP-1787 approach.

      +
      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.

      +
    • +
    • +
      Foreign public archives (Public Gallery drill-in)Not started
      + GET /v2/folders/{id}/children (bearer) +

      iOS wired it in PR #576 (2026-07-29): children serves a foreign public tree on bearer auth; root must stay V1 getPublicRoot (/v2/archives lists own memberships only). Android's PublicArchiveViewModel/PublicFolderViewModel still V1; note the deep-link path lacks folderId.

    • Record detail · viewer readsNot started
      @@ -198,10 +208,10 @@

      Android Stela Migration — Status Board

      Missing routes or data — split by when we need it

        -
      • Blocks the current work (VSP-1778)
      • +
      • Blocks the flag flip (VSP-1778 · Private Files only)
      • -
        Pending shares-to-archives in children shares[]Blocked
        -

        Verified 2026-07-23: V2 filters shares[] to accepted only and pendingShares[] covers email invites only — a pending share to an existing archive is in neither list, so the pending badge undercounts behind the flag. The one thing to fix before the production flag flip.

        +
        Pending shares-to-archives in children shares[] — folder items onlyBlocked
        +

        Refined + live-verified on staging 2026-07-31 (one children capture): record items already include pending shares (get_records.sql keeps everything but deleted — record faux-potted-cactus returned its status.generic.pending share); folder items drop them (get_folders.sql filters to status.generic.ok — the Vacation folder still omits pending shareId 2010). One-line backend fix: align the folder filter with the record one. The Android mapper already handles pending entries in shares[], so no app change needed. Still the one thing to fix before the production flag flip.

      • Findings for future tickets — not scheduled yet
      • @@ -239,7 +249,7 @@

        Android Stela Migration — Status Board

        diff --git a/docs/stela/folders-children-contract.md b/docs/stela/folders-children-contract.md index 6e2f17d6..b9f7ead7 100644 --- a/docs/stela/folders-children-contract.md +++ b/docs/stela/folders-children-contract.md @@ -1,10 +1,39 @@ # Android · Stela V2 — GET /v2/folders/{id}/children (verified contract) -Verified against the merged iOS implementation (permanent-ios PR #573, commit `38d9622`), -live production captures (2026-07-22), and the published stela docs — in that order of -authority. Written for VSP-1778 (Private Files navigation); reuse for future Stela tickets +Verified against the merged iOS implementation (permanent-ios PR #573, commit `38d9622`; +updated 2026-07-30 against PRs #574/#575/#576/#580), 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); reuse for future Stela tickets instead of re-deriving. +## Public folders (VSP-1808) + +- **Same endpoint, same bearer auth, no public variant.** The children call for the owner's + Public Files tree is byte-identical to the private one — only root resolution differs + (Android keeps V1 `folder/getPublicRoot`; the V1 response carries `folderId`, so the V2 + fork engages from the root down). iOS does the same drill-in (their PR #573 already + covered Public Files via ViewModel inheritance) and moved root discovery to + `GET /v2/archives` → root children → public-root child in a separate ticket + (VSP-1787, PR #574) — Android's counterpart is a future ticket for both sections. +- **Foreign public archives** (another archive's public tree): iOS verified on staging + (2026-07-28, their PR #576) that `/children` serves it on plain bearer auth — but root + discovery must stay V1 `getPublicRoot`, because `/v2/archives` only lists the caller's + own memberships. Android's `PublicArchiveViewModel`/`PublicFolderViewModel` are still + fully V1 (out of VSP-1808 scope; candidate next ticket). +- Backend gaps 1–2 don't bite Public Files: item permissions are archive-derived there, and + the pending-invitation badge is not rendered on that screen. + +## Feature flag environment gating (fixed in VSP-1808) + +`FeatureFlags.useStelaMigration = BuildConfig.STELA_MIGRATION_DEFAULT` — a per-flavor +boolean `buildConfigField` in `app/build.gradle` (`true` in staging, `false` in +production), so the flavor block owns the environment fact and the rollout flip is a +one-word gradle edit. Before the fix the flag was `BuildConfig.DEBUG`, which let a +productionDebug build send V2 calls to the production API — the exact leak iOS closed in +PR #575, refined in PR #580 after their QA's release-type staging build silently pinned +the flag OFF. Net rule on both platforms: flag on ⇔ environment is staging, in every +build type. + ## Request ``` @@ -64,8 +93,8 @@ merged iOS code ignores it and discriminates by id presence; Android does the sa | `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`). | | `size` | Bytes; present on folders too. | -| `shares[]` | `{ id, accessRole, status, archive { id, archiveNumber, name, thumbs } }`. **Populated for owner bearer requests** (verified live on staging 2026-07-23) — the earlier share-token capture's `null` was flavor-specific. But server-side it is **filtered to `status.generic.ok`** (`get_folders.sql:76`), so every entry is OK-status. Feeds the shared/pending badge only — **item permissions stay archive-derived** (see gaps). | -| `pendingShares[]` | `{ id, email, name, accessRole }` — pending **email invitations** only (`invite_share` table, `get_folders.sql:81-99`). Pending **shares to existing archives** (`share` rows with `status.generic.pending`) appear in **neither array** — see gap 2. | +| `shares[]` | `{ id, accessRole, status, archive { id, archiveNumber, name, thumbs } }`. **Populated for owner bearer requests** (verified live on staging 2026-07-23) — the earlier share-token capture's `null` was flavor-specific. Status filtering **differs by item kind** (2026-07-31): **record items** keep everything but deleted (`get_records.sql`), so `status.generic.pending` entries appear; **folder items** are filtered to `status.generic.ok` (`get_folders.sql`) — see gap 2. Feeds the shared/pending badge only — **item permissions stay archive-derived** (see gaps). | +| `pendingShares[]` | `{ id, email, name, accessRole }` — pending **email invitations** only (`invite_share` table), and only for owner/manager callers. Pending **shares to existing archives** (`share` rows with `status.generic.pending`) ride in `shares[]` on **record items** but are absent from **both** arrays on **folder items** — see gap 2. | ### Thumbnails — the rules that matter @@ -77,9 +106,15 @@ merged iOS code ignores it and discriminates by id presence; Android does the sa Android reads the **nested** object only (covers records and folders alike; the flat `thumbUrl*` duplicates are not read on V2), with flat `thumbnail256` preferred when present. -- **Never use nested `thumbnailUrls."256"`** — it is the Archivematica access-copy thumbnail, - a tiny rendition that comes back **blank for HEIC**. Only a real flat `thumbnail256` counts - as the 256 source; otherwise fall through to the `.thumb.wNNN` renditions. +- **Nested `thumbnailUrls."256"` is a LAST resort only, never for HEIC** *(refined 2026-07-30 + per iOS PR #575, replacing the earlier "never use" rule)*: it is the Archivematica + access-copy thumbnail, blank for HEIC originals (white square). The `.thumb.wNNN` renditions + always win — but a record created via the Stela V2 copies endpoint gets **no** renditions + (backend gap), so the access copy is the only thumbnail it has. Android + (`ItemMapper.accessCopyThumb256`) uses it as the final fallback in the 200 slot, guarded by + HEIC detection (`files[]` original format/type first, `uploadFileName`/`downloadName` + suffix fallback). The 256/blur slot stays flat-`thumbnail256`-only, matching iOS + `resolvedThumb256`. - A record with no `thumbnail256` and no nested 200 is treated as still processing (matches V1's spinner behavior for fresh uploads). - Thumbnail URLs may arrive as **empty strings instead of null** — treat empty as absent. @@ -101,12 +136,14 @@ merged iOS code ignores it and discriminates by id presence; Android does the sa the deprecated alias (Android's existing `StelaAccountService.getFolder` still uses it for share management — migrate opportunistically). -## Impact summary (as of 2026-07-23) +## Impact summary (updated 2026-07-31) With the flag ON, only one thing visibly breaks on Private Files: the **pending badge -undercounts** (gap 2 below). Everything else falls back to V1 or is handled in the app. -Gaps 1 and 2 are the same backend theme — *send complete share data on children* — so raise -them as one ask, together with iOS's existing P2. Gaps 3–4 break nothing today; they only +undercounts on FOLDER rows** (gap 2 below — record rows are fine, live-verified +2026-07-31). Everything else falls back to V1 or is handled in the app. Gaps 1 and 2 are +the same backend theme — *send complete share data on children* — so raise them as one +ask, together with iOS's existing P2; for gap 2 the concrete ask is a one-line filter +alignment (raised with the backend 2026-07-31). Gaps 3–4 break nothing today; they only block future migration tickets, and those surfaces simply stay on V1. ## Known backend gaps (as of 2026-07-23) @@ -115,19 +152,37 @@ block future migration tickets, and those surfaces simply stay on V1. (descendants inside a shared tree carry none — the hydration SQL only aggregates direct share rows). Blocks Shared-workspace drill-in on both platforms; iOS P2 backend ask. Own-archive browsing is unaffected (permissions are archive-derived). -2. **V2 does not send pending shares-to-archives — so the pending badge misses them.** +2. **V2 drops pending shares-to-archives on FOLDER items only — so the pending badge + undercounts on folders.** *(Refined 2026-07-31 after backend follow-up + stela source + verification; supersedes the earlier "in neither list for all items" wording.)* There are two kinds of "pending" share: an **email invite** (the person has no account - yet) and a **share to an existing archive** (not accepted yet). V1 sends both in one - list (`ShareVOs`), so the badge works today. V2 loses the second kind: - - `shares[]` keeps only **accepted** shares — the backend filters - `status = 'status.generic.ok'` (`get_folders.sql:76`); - - `pendingShares[]` contains only **email invites** (`invite_share`, lines 81–99). - A pending share to an archive matches neither, so it is in **neither list**. - Verified on staging 2026-07-23: Vacation folder (`folder_linkId 115425`) has pending - `shareId 2010` in the V1 response — it is absent from the V2 response. - **Result:** with the flag ON, the badge undercounts. The app cannot show data it never - receives — only the backend can fix this. **Ask:** also send pending `share` rows - (in `shares[]` or a third list). Fix needed before the production flag flip. + yet) and a **share to an existing archive** (not accepted yet, e.g. via a restricted + share link with auto-approve off + a request-access click). V1 sends both in one list + (`ShareVOs`). On V2 the children route (`folder/service.ts:getFolderChildren`) hydrates + the two item kinds through **different queries with different share filters**: + - **record items** → `get_records.sql`: `share.status != 'status.generic.deleted'` — + pending shares to archives **ARE included** in `shares[]` (with + `status.generic.pending`). Confirmed by the backend on a live `/records` response + and **verified live through the children call on staging (2026-07-31)**: record + `faux-potted-cactus` (`folder_linkId 115428`, inside the Vacation folder) returns + `{ id: 2187, status: status.generic.pending, accessRole: access.role.viewer }`; + - **folder items** → `get_folders.sql`: `share.status = 'status.generic.ok'` — + pending shares to archives are **EXCLUDED**; + - `pendingShares[]` contains only **email invites** (`invite_share`) on both, and only + for owner/manager callers. + Our staging capture (2026-07-23, Vacation **folder**, `folder_linkId 115425`, pending + `shareId 2010` present on V1 and absent on V2) and the backend's record example are + both consistent with this split — and the folder side was **re-confirmed live in the + same 2026-07-31 children capture** (the Vacation folder item still returns only its OK + owner share; 2010 remains absent). + **Android impact:** `ItemMapper.buildShares` already normalizes + `status.generic.pending` inside `shares[]`, so record rows badge correctly today and + folder rows will start working with **no app change** once the backend aligns the + filter. **Ask (one line):** make `get_folders.sql` use the same + `!= 'status.generic.deleted'` filter as `get_records.sql`. Fix needed before the + production flag flip. Both sides are live-verified on staging (2026-07-31, one + children capture session): pending present on the record item, absent on the folder + item. 3. **No V2 folder-creation endpoint** (`POST /v2/folders` does not exist) — folder creation stays on V1 `folder/post`. Blocks the folder-creation migration follow-up ticket. 4. **Folder PATCH** (`PATCH /v2/folders/{id}`) now appears in the stela docs; the @@ -144,3 +199,5 @@ block future migration tickets, and those surfaces simply stay on V1. | Reliable `nextCursor`/`totalPages` | Both unreliable (above) | | Uniform dotted enum values | Folders use short forms, records dotted | | iOS gated by a "remote flag" | iOS's merged flag is a compile-time constant (Android mirrors: `FeatureFlags.useStelaMigration`) | +| 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`) | diff --git a/docs/stela/vsp-1772-android-impact-inventory.md b/docs/stela/vsp-1772-android-impact-inventory.md index b7284727..7ce8673b 100644 --- a/docs/stela/vsp-1772-android-impact-inventory.md +++ b/docs/stela/vsp-1772-android-impact-inventory.md @@ -245,6 +245,7 @@ The app's own share-preview integration already bypasses pagination with `pageSi 4. **`thumbStatus` / processing state.** No equivalent in the new contract. Is "empty `thumbnailUrls` = still processing" the sanctioned derivation? 5. **`thumbnailUrls` shape.** ✅ **Resolved July 22, 2026 (live response):** *both* shapes coexist on records — flat `thumbUrl200/500/1000/2000` **and** nested `thumbnailUrls{"200"…"2000"}`; **folders return only the nested object** (which includes `"256"`). Use the nested `thumbnailUrls` for both item types; the flat fields the current `ItemDTO` reads are record-only legacy duplicates. 6. **`shares[]` population for recipients.** ⚠️ **Confirmed as a backend gap — on all environments, staging included (July 22, 2026):** the live capture returned `shares`/`pendingShares` as `null`, and the stela `main`-branch source confirms why: the children hydration query (`packages/api/src/folder/queries/get_folders.sql`) only aggregates share rows attached *directly* to an item's own `folder_linkId`, so descendants inside a shared tree carry none and a recipient cannot derive a per-item role. It's a contract/data-model limitation, not a deployment lag (no open stela PR changes it) — matching the iOS P2 backend ask ("child.accessRole (caller-effective) + complete shares[]"). **Android's Shares-surface permission derivation (§4.2) is backend-blocked on that ask** — track it with the backend team before scheduling that slice; all other surfaces (own/public archive) are unaffected. + *Update 2026-07-31 (this snapshot is superseded on two points — see `folders-children-contract.md`, the living doc):* (a) the `null` shares in the July 22 capture were flavor/context-specific — **owner bearer requests do get `shares[]`** on directly-shared items (verified live 2026-07-23); the recipient/descendant part of this gap stands. (b) Share **status filtering differs by item kind**: record items are hydrated via `get_records.sql` (keeps `status.generic.pending` entries — live-verified through the children call), folder items via `get_folders.sql` (filters to `status.generic.ok`, dropping pending shares-to-archives). One-line backend filter alignment requested 2026-07-31. 7. **Pagination limits.** Partially resolved July 22: `pageSize=99999999` is accepted (no cap error). But `totalPages` came back **0** despite 9 items (unreliable — don't build on it), `nextCursor` was **non-null even when the full folder fit in one page** (end-of-list detection can't rely on a null cursor; probe the next page or compare counts), and the response includes an undocumented `pagination.nextPage` convenience URL (which itself uses the plural `/folders/` path). 8. **Value formats.** Resolved July 22: **records keep legacy dotted forms** (`type.record.image`, `status.generic.ok`) and dates are ISO-8601 (existing `replace("T", " ")` logic still applies) — but **folders use new short forms**: `type: "private"`, `status: "ok"`, `sort: "alphabetical-ascending"`. Any mapping that assumes `type.folder.*` on folders will break. Confirmed intentional and environment-independent (staging included): stela's folder service applies explicit `prettifyFolderType/Status` transforms (`packages/api/src/folder/service.ts:49`) over the dotted DB enums; the record model has no such layer, hence the mixed forms in one response. `accessRole` strings still unverified (see #6). 9. **Root folder ids.** ✅ **Resolved July 22, 2026 (via iOS contract sheet/VSP-1787):** iOS replaced `getRoot` with `GET /api/v2/archives` → `items[].rootFolderId` → `/children` → section-root child (`type.folder.root.private` = My Files). Android can mirror this chain. Residual: Android's public browsing also uses `getPublicRoot`, which on iOS remains V1-bootstrapped for foreign public archives ("confirmed solvable, not yet wired") — decide whether Android's phase 1 keeps `getPublicRoot` (V1) for root resolution as iOS does.