Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class FileUploadResult (
var name: String,
@SerialName("updated_collection_hash") var updatedCollectionHash: String
) : SerializableMarker
@SerialName("updated_collection_hash") var updatedCollectionHash: String,
@SerialName("file_hash") var fileHash: String? = null
) : SerializableMarker
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class SnowbirdBridge {

@JvmStatic
fun updateStatusFromRust(code: Int, message: String) {
instance?._status?.value = SnowbirdServiceStatus.fromCode(code)
// Preserve error context from Rust when available.
instance?._status?.value = SnowbirdServiceStatus.fromCode(code, message)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,13 +519,16 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() {
private fun onFileUploaded(result: FileUploadResult) {
handleLoadingStatus(false)
Timber.d("File successfully uploaded: $result")
SnowbirdFileItem(
name = result.name,
hash = result.updatedCollectionHash,
groupKey = groupKey,
repoKey = repoKey,
isDownloaded = true
).save()
val uploadedHash = result.fileHash
if (!uploadedHash.isNullOrBlank()) {
SnowbirdFileItem(
name = result.name,
hash = uploadedHash,
groupKey = groupKey,
repoKey = repoKey,
isDownloaded = true
).save()
}
snowbirdFileViewModel.fetchFiles(groupKey, repoKey, forceRefresh = false)
}

Expand Down Expand Up @@ -564,4 +567,4 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() {
const val RESULT_VAL_RAVEN_GROUP_KEY = "dweb_group_key"
const val RESULT_VAL_RAVEN_REPO_KEY = "dweb_repo_key"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package net.opendasharchive.openarchive.services.snowbird
import android.net.Uri
import net.opendasharchive.openarchive.db.FileUploadResult
import net.opendasharchive.openarchive.db.SnowbirdFileItem
import net.opendasharchive.openarchive.db.SnowbirdError
import net.opendasharchive.openarchive.db.toFile
import net.opendasharchive.openarchive.extensions.toSnowbirdError
import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI
import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus
import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService

interface ISnowbirdFileRepository {
suspend fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean = false): SnowbirdResult<List<SnowbirdFileItem>>
Expand All @@ -15,6 +18,14 @@ interface ISnowbirdFileRepository {

class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository {

private fun ensureServerReadyForNetwork(): SnowbirdResult.Error? {
return if (SnowbirdService.getCurrentStatus() is ServiceStatus.Connected) {
null
} else {
SnowbirdResult.Error(SnowbirdError.GeneralError("DWeb server is not ready. Enable DWeb Server and wait for it to connect."))
}
}

override suspend fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean): SnowbirdResult<List<SnowbirdFileItem>> {
return if (forceRefresh) {
fetchFilesFromNetwork(groupKey, repoKey)
Expand All @@ -29,6 +40,7 @@ class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository {

private suspend fun fetchFilesFromNetwork(groupKey: String, repoKey: String): SnowbirdResult<List<SnowbirdFileItem>> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.fetchFiles(groupKey, repoKey)
val files = response.files.map { it.toFile(groupKey = groupKey, repoKey = repoKey) }
SnowbirdResult.Success(files)
Expand All @@ -39,6 +51,7 @@ class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository {

override suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): SnowbirdResult<ByteArray> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.downloadFile(groupKey, repoKey, filename)
SnowbirdResult.Success(response)
} catch (e: Exception) {
Expand All @@ -48,6 +61,7 @@ class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository {

override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): SnowbirdResult<FileUploadResult> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.uploadFile(groupKey, repoKey, uri)
SnowbirdResult.Success(response)
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class SnowbirdGroupListFragment : BaseSnowbirdFragment() {
setupRecyclerView()
initializeViewModelObservers()

snowbirdGroupViewModel.fetchGroups()
// Use network on first load so new memberships appear without restart.
snowbirdGroupViewModel.fetchGroups(forceRefresh = true)
}

private fun setupSwipeRefresh() {
Expand Down Expand Up @@ -191,4 +192,4 @@ class SnowbirdGroupListFragment : BaseSnowbirdFragment() {
override fun getToolbarTitle(): String {
return "My Groups"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import net.opendasharchive.openarchive.db.JoinGroupResponse
import net.opendasharchive.openarchive.db.MembershipRequest
import net.opendasharchive.openarchive.db.RequestName
import net.opendasharchive.openarchive.db.SnowbirdGroup
import net.opendasharchive.openarchive.db.SnowbirdError
import net.opendasharchive.openarchive.extensions.toSnowbirdError
import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI
import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus
import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService

interface ISnowbirdGroupRepository {
suspend fun createGroup(groupName: String): SnowbirdResult<SnowbirdGroup>
Expand All @@ -18,8 +21,17 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository
private var lastFetchTime: Long = 0
private val cacheValidityPeriod: Long = 5 * 60 * 1000

private fun ensureServerReadyForNetwork(): SnowbirdResult.Error? {
return if (SnowbirdService.getCurrentStatus() is ServiceStatus.Connected) {
null
} else {
SnowbirdResult.Error(SnowbirdError.GeneralError("DWeb server is not ready. Enable DWeb Server and wait for it to connect."))
}
}

override suspend fun createGroup(groupName: String): SnowbirdResult<SnowbirdGroup> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.createGroup(
RequestName(groupName)
)
Expand All @@ -31,6 +43,7 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository

override suspend fun fetchGroup(groupKey: String): SnowbirdResult<SnowbirdGroup> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.fetchGroup(groupKey)
SnowbirdResult.Success(response)
} catch (e: Exception) {
Expand All @@ -42,7 +55,7 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository
val currentTime = System.currentTimeMillis()
val shouldFetchFromNetwork = forceRefresh || currentTime - lastFetchTime > cacheValidityPeriod

return if (forceRefresh) {
return if (shouldFetchFromNetwork) {
fetchFromNetwork()
} else {
fetchFromCache()
Expand All @@ -51,6 +64,7 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository

override suspend fun joinGroup(uriString: String): SnowbirdResult<JoinGroupResponse> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.joinGroup(
MembershipRequest(uriString)
)
Expand All @@ -63,7 +77,9 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository

private suspend fun fetchFromNetwork(): SnowbirdResult<List<SnowbirdGroup>> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.fetchGroups()
lastFetchTime = System.currentTimeMillis()
SnowbirdResult.Success(response.groups)
} catch (e: Exception) {
SnowbirdResult.Error(e.toSnowbirdError())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ class SnowbirdGroupViewModel(
viewModelScope.launch {
_groupState.value = GroupState.Loading
try {
val result = processingTracker.trackProcessingWithTimeout(60_000, "fetch_groups") {
// Use longer timeout for refresh operations that may need to download collections from peers
val timeoutMs = if (forceRefresh) 120_000L else 60_000L
val result = processingTracker.trackProcessingWithTimeout(timeoutMs, "fetch_groups") {
repository.fetchGroups(forceRefresh)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import kotlinx.serialization.Serializable
import net.opendasharchive.openarchive.db.RefreshGroupResponse
import net.opendasharchive.openarchive.db.RequestName
import net.opendasharchive.openarchive.db.SnowbirdGroup
import net.opendasharchive.openarchive.db.SnowbirdError
import net.opendasharchive.openarchive.db.SnowbirdRepo
import net.opendasharchive.openarchive.db.toRepo
import net.opendasharchive.openarchive.extensions.toSnowbirdError
import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI
import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus
import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService
import timber.log.Timber

interface ISnowbirdRepoRepository {
Expand All @@ -19,10 +22,19 @@ interface ISnowbirdRepoRepository {

class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository {

private fun ensureServerReadyForNetwork(): SnowbirdResult.Error? {
return if (SnowbirdService.getCurrentStatus() is ServiceStatus.Connected) {
null
} else {
SnowbirdResult.Error(SnowbirdError.GeneralError("DWeb server is not ready. Enable DWeb Server and wait for it to connect."))
}
}

override suspend fun createRepo(groupKey: String, repoName: String): SnowbirdResult<SnowbirdRepo> {
Timber.d("Creating repo: groupKey=$groupKey, repoName=$repoName")

return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.createRepo(groupKey, RequestName(repoName))
val repo = response.toRepo(groupKey)
SnowbirdResult.Success(repo)
Expand All @@ -41,6 +53,7 @@ class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository {

private suspend fun fetchFromNetwork(groupKey: String): SnowbirdResult<List<SnowbirdRepo>> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.fetchRepos(groupKey)
val repoList = response.repos.map { it.toRepo(groupKey) }
SnowbirdResult.Success(repoList)
Expand All @@ -55,6 +68,7 @@ class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository {

override suspend fun refreshGroupContent(groupKey: String): SnowbirdResult<RefreshGroupResponse> {
return try {
ensureServerReadyForNetwork()?.let { return it }
val response = api.refreshGroupContent(groupKey)
SnowbirdResult.Success(response)
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,37 @@ class SnowbirdRepoViewModel(

is SnowbirdResult.Success<RefreshGroupResponse> -> {
AppLogger.i("Group content refreshed successfully")
//TODO: Save Repo List and Media List to DB

// Get existing repos for group
// Only persist refresh data for repos that belong to this group.
val allowedRepoIds: Set<String> = run {
val fromNetwork = repository.fetchRepos(groupKey, forceRefresh = true)
when (fromNetwork) {
is SnowbirdResult.Success -> fromNetwork.value.map { it.key }.toSet()
is SnowbirdResult.Error -> {
AppLogger.w("Unable to fetch repos for scoping refresh; falling back to local DB scope: ${fromNetwork.error.friendlyMessage}")
SnowbirdRepo.getAllForGroupKey(groupKey).map { it.key }.toSet()
}
}
}

val existingRepos = SnowbirdRepo.getAllForGroupKey(groupKey)
val existingReposMap = existingRepos.associateBy { it.key }

val repoErrors = mutableListOf<String>()

result.value.refreshedRepos.forEach { repoData ->
if (allowedRepoIds.isNotEmpty() && !allowedRepoIds.contains(repoData.repoId)) {
AppLogger.e("Refresh returned repo outside group scope. groupKey=$groupKey repoId=${repoData.repoId} name=${repoData.name}")
return@forEach
}

// Log repo errors if any
if (!repoData.error.isNullOrEmpty()) {
AppLogger.e("Error refreshing repo ${repoData.repoId}: ${repoData.error}")
val bucket = classifyRefreshError(repoData.error)
val msg = "Repo ${repoData.name} (${repoData.repoId}): $bucket — ${repoData.error}"
repoErrors.add(msg)
AppLogger.e(msg)
}

// Update or create repo
val snowbirdRepo = existingReposMap[repoData.repoId] ?: repoData.toRepo().apply {
this.groupKey = groupKey
}
Expand All @@ -113,29 +130,34 @@ class SnowbirdRepoViewModel(
permissions = if (repoData.canWrite) "READ_WRITE" else "READ_ONLY"
}.save()

// Get existing files for this repo
val existingFiles = SnowbirdFileItem.findBy(groupKey, repoData.repoId)
val existingFilesMap = existingFiles.associateBy { it.name }

// Process all files (not just refreshed ones)
repoData.allFiles.forEach { fileName ->
val existingFile = existingFilesMap[fileName]

if (existingFile == null) {
// Create new file if it doesn't exist
SnowbirdFileItem(
name = fileName,
repoKey = repoData.repoId,
groupKey = groupKey,
).save()
} else {
// Update existing file without overwriting with null
// Note: The refresh API doesn't provide file details,
// so we just maintain the existing file record
}
}
}
_repoState.value = RepoState.RefreshGroupContentSuccess

// Surface per-repo refresh failures while keeping persisted updates.
if (repoErrors.isNotEmpty()) {
val summary = repoErrors.take(8).joinToString("\n")
val suffix = if (repoErrors.size > 8) "\n… and ${repoErrors.size - 8} more" else ""
_repoState.value = RepoState.Error(
SnowbirdError.GeneralError(
"Some repositories failed to refresh:\n$summary$suffix\n\n(Types: DHT_DISCOVERY vs PEER_DOWNLOAD vs UNKNOWN)"
)
)
} else {
_repoState.value = RepoState.RefreshGroupContentSuccess
}
fetchRepos(groupKey = groupKey)
}
}
Expand All @@ -145,4 +167,13 @@ class SnowbirdRepoViewModel(
}
}
}
}

private fun classifyRefreshError(message: String): String {
val m = message.lowercase()
return when {
"dht" in m || "repo root hash" in m -> "DHT_DISCOVERY"
"download from any peer" in m || "any peer" in m -> "PEER_DOWNLOAD"
else -> "UNKNOWN"
}
}
}
Loading
Loading