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
3 changes: 3 additions & 0 deletions app/src/main/java/org/permanent/permanent/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -852,6 +853,8 @@ class NetworkClient(private var okHttpClient: OkHttpClient?, context: Context) {
pageSize: Int = StelaAccountService.MAX_CHILDREN_PAGE_SIZE
): Call<FolderChildrenResponse> = stelaAccountService.getFolderChildrenV2(folderId, pageSize)

fun getArchivesV2(): Call<ArchivesV2Response> = stelaAccountService.getArchives()

fun getRecordV2(recordId: Int): Call<RecordResponse> = stelaAccountService.getRecord(recordId)

fun getFolderV2(folderId: Int, shareToken: String? = null): Call<FolderResponse> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +51,16 @@ interface StelaAccountService {
@Query("pageSize") pageSize: Int = 99999999
): Call<FolderChildrenResponse>

// 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<String> = ALL_MEMBERSHIP_ROLES,
@Query("pageSize") pageSize: Int = ARCHIVES_PAGE_SIZE
): Call<ArchivesV2Response>

// 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")
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.permanent.permanent.network.models

data class ArchivesV2Response(
val items: List<ArchiveV2DTO>?
)

// 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?
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -42,13 +45,7 @@ class FileRepositoryImpl(val context: Context) : IFileRepository {
NetworkClient.instance().getRoot().enqueue(object : Callback<ResponseVO> {
override fun onResponse(call: Call<ResponseVO>, response: Response<ResponseVO>) {
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) {
Expand All @@ -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<ArchivesV2Response> {

override fun onResponse(
call: Call<ArchivesV2Response>,
response: Response<ArchivesV2Response>
) {
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<ArchivesV2Response>, 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<Record>) {
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<Record>, 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<ResponseVO> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 6 additions & 23 deletions app/src/main/java/org/permanent/permanent/ui/PreferencesHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> =
MutableLiveData(SortType.NAME_ASCENDING.toUIString())
Expand Down Expand Up @@ -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) {
Expand All @@ -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<Boolean>) {
this.existsDownloads = existsDownloads
}
Expand Down
Loading