Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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/\""
Expand All @@ -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/\""
Expand Down
17 changes: 9 additions & 8 deletions app/src/main/java/org/permanent/permanent/FeatureFlags.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 24 additions & 9 deletions app/src/main/java/org/permanent/permanent/mapper/ItemMapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ data class ItemDTO(
val thumbnailUrls: ThumbnailUrlsDTO? = null,
val shares: List<ItemShareDTO>? = null,
val pendingShares: List<PendingShareDTO>? = 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<FileDTO>? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void?>()

init {
getFolderName().value = Constants.PUBLIC_FILES

PermanentApplication.instance.relocateData?.let {
setRelocationMode(it)
}
}

override fun loadRootFiles() {
Expand All @@ -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()
}
Expand Down
28 changes: 19 additions & 9 deletions docs/stela/android-stela-status-board.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@
<header>
<p class="eyebrow">Permanent Android · Stela backend migration</p>
<h1>Android Stela Migration — Status Board</h1>
<p class="meta">Flag: compile-time <code>FeatureFlags.useStelaMigration</code> (debug ON / release OFF) ·
Updated <strong>2026-07-24</strong> · edit by hand, one item per surface.</p>
<p class="meta">Flag: compile-time <code>FeatureFlags.useStelaMigration</code> (staging flavor ON — any build type / production OFF, per iOS PRs #575/#580) ·
Updated <strong>2026-07-30</strong> · edit by hand, one item per surface.</p>
</header>

<div class="cols">
Expand All @@ -126,6 +126,11 @@ <h1>Android Stela Migration — Status Board</h1>
<code class="route">GET /v2/folders/{id}/children</code>
<p class="note">VSP-1778 · replaces navigateMin + getLeanItems · single fetch <code>pageSize=99999999</code>, local sort, V1 failsafe, supersede policy. Known gap: pending badge undercounts (see backend column).</p>
</li>
<li class="item">
<div class="row"><span class="name">Public Files navigation &amp; listing</span><span class="pill st-flag">Behind flag</span></div>
<code class="route">GET /v2/folders/{id}/children</code>
<p class="note">VSP-1808 · same seam as Private Files (inherited via <code>PublicFilesViewModel</code>), same invariants · root stays V1 <code>getPublicRoot</code> · thumbnail rule refined (access copy = HEIC-guarded last resort, iOS PR #575) · pending-badge gap doesn't apply (badge not rendered here).</p>
</li>
<li class="item">
<div class="row"><span class="name">Share-link preview grid</span><span class="pill st-live">Live</span></div>
<code class="route">GET /v2/folder/{id}/children (share token)</code>
Expand Down Expand Up @@ -164,9 +169,14 @@ <h1>Android Stela Migration — Status Board</h1>
</div>
<ul>
<li class="item">
<div class="row"><span class="name">My Files root discovery</span><span class="pill st-todo">Not started</span></div>
<code class="route">GET /v2/archives → rootFolderId → /children</code>
<p class="note">VSP-1778 keeps V1 <code>getRoot</code> as bootstrap (same as iOS PR #573); the archives chain is iOS's VSP-1787 approach.</p>
<div class="row"><span class="name">Root discovery (My Files + Public Files)</span><span class="pill st-todo">Not started</span></div>
<code class="route">GET /v2/archives → rootFolderId → /children → section-root child</code>
<p class="note">VSP-1778/VSP-1808 keep V1 <code>getRoot</code>/<code>getPublicRoot</code> as bootstrap; the archives chain is iOS's VSP-1787 (PR #574), which resolves both section roots — one future ticket covers both.</p>
</li>
<li class="item">
<div class="row"><span class="name">Foreign public archives (Public Gallery drill-in)</span><span class="pill st-todo">Not started</span></div>
<code class="route">GET /v2/folders/{id}/children (bearer)</code>
<p class="note">iOS wired it in PR #576 (2026-07-29): children serves a foreign public tree on bearer auth; root must stay V1 <code>getPublicRoot</code> (<code>/v2/archives</code> lists own memberships only). Android's <code>PublicArchiveViewModel</code>/<code>PublicFolderViewModel</code> still V1; note the deep-link path lacks <code>folderId</code>.</p>
</li>
<li class="item">
<div class="row"><span class="name">Record detail · viewer reads</span><span class="pill st-todo">Not started</span></div>
Expand Down Expand Up @@ -198,10 +208,10 @@ <h1>Android Stela Migration — Status Board</h1>
<p class="sub">Missing routes or data — split by when we need it</p>
</div>
<ul>
<li class="group">Blocks the current work (VSP-1778)</li>
<li class="group">Blocks the flag flip (VSP-1778 · Private Files only)</li>
<li class="item">
<div class="row"><span class="name">Pending shares-to-archives in children <code>shares[]</code></span><span class="pill st-block">Blocked</span></div>
<p class="note">Verified 2026-07-23: V2 filters <code>shares[]</code> to accepted only and <code>pendingShares[]</code> 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.</p>
<div class="row"><span class="name">Pending shares-to-archives in children <code>shares[]</code> — folder items only</span><span class="pill st-block">Blocked</span></div>
<p class="note">Refined + live-verified on staging 2026-07-31 (one children capture): <strong>record items already include pending shares</strong> (<code>get_records.sql</code> keeps everything but deleted — record <code>faux-potted-cactus</code> returned its <code>status.generic.pending</code> share); <strong>folder items drop them</strong> (<code>get_folders.sql</code> filters to <code>status.generic.ok</code> — the Vacation folder still omits pending <code>shareId 2010</code>). One-line backend fix: align the folder filter with the record one. The Android mapper already handles pending entries in <code>shares[]</code>, so no app change needed. Still the one thing to fix before the production flag flip.</p>
</li>
<li class="group">Findings for future tickets — not scheduled yet</li>
<li class="item">
Expand Down Expand Up @@ -239,7 +249,7 @@ <h1>Android Stela Migration — Status Board</h1>
</div>

<footer>
Sources: merged iOS PR #573 (tier 1) · iOS contract sheet &amp; status boards (tier 2) ·
Sources: merged iOS PRs #573–#576, #580 (tier 1) · iOS contract sheet &amp; status boards (tier 2) ·
<code>docs/stela/folders-children-contract.md</code> · VSP-1772 impact inventory.
Keep this file the single source of truth for the Android migration surface — update the item when a ticket lands.
</footer>
Expand Down
Loading