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
27 changes: 27 additions & 0 deletions ADAPTER_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,33 @@ Stored Login Flow app passwords must not be treated as primary passwords for
strict administrator confirmation. The client must not collect or retain a
primary account password to bypass that boundary.

### Administration visibility

`AdministrationAccessRepository` owns session-scoped, in-memory permission
evidence from the existing read-only administrator catalog contract. It reuses
that catalog for the Server apps screen and caches both allowed and denied
results for five minutes using a monotonic clock. Opening Settings checks the
cache; while the root Settings screen or Server apps is visible, expired evidence
is refreshed. Opening a child screen from Settings stops automatic polling.
Polling also stops when Android leaves the started lifecycle or the desktop
window is hidden or minimized. Returning to a visible screen reuses fresh
evidence or revalidates expired evidence. Settings retains the requested section
while Administration is hidden during a check, without rendering its controls.
No permission result survives logout, account replacement, or process restart.
Both catalog request paths use `ForceNetwork` so revalidation cannot renew access
from the transport's persisted response cache.

Administration, its installed-workspace summary, and the Server apps catalog
require fresh successful evidence. Unknown, expired, denied, malformed, and
unavailable results hide those surfaces, including restored navigation. The
Server apps route keeps a loading or typed failure view with retry and Back
instead of navigating away silently when permission revalidation fails. An
explicit refresh removes old access before requesting new evidence. Concurrent
checks share the cached result, and cancellation cannot publish a late success.
The ordinary Apps workspace remains available to regular users. Cached visibility
never authorizes a mutation; strict operations still use authenticated browser
handoff and server-side authorization.

## Error and cancellation rules

Errors must retain enough structured context to support recovery and safe
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
Expand All @@ -18,12 +19,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import dev.obiente.nextcloudnative.app.LocalAppWindowVisibility
import kotlinx.coroutines.flow.MutableStateFlow
import dev.obiente.nextcloudnative.app.NextcloudNativeApp
import dev.obiente.nextcloudnative.app.NextcloudNativeLinkRequest
import dev.obiente.nextcloudnative.app.ThemePreference
import java.util.UUID

class MainActivity : ComponentActivity() {
private val windowVisibility = MutableStateFlow(false)
private var appUpdateReviewRequest by mutableLongStateOf(0L)
private var lastAppUpdateReviewEventId: Long? = null
private var platformCapabilityRefreshRequest by mutableLongStateOf(0L)
Expand Down Expand Up @@ -131,24 +135,36 @@ class MainActivity : ComponentActivity() {
window.decorView.setBackgroundColor(background.toArgb())
}

NextcloudNativeApp(
services = services,
appUpdateReviewRequest = appUpdateReviewRequest,
platformCapabilityRefreshRequest = platformCapabilityRefreshRequest,
linkRequest = incomingLinkRequests.firstOrNull(),
onLinkRequestHandled = { sequence ->
if (incomingLinkRequests.firstOrNull()?.sequence == sequence) {
incomingLinkRequests.removeAt(0)
}
},
linkQueueOverflowEvent = incomingLinkQueueOverflowEvent,
onLinkQueueOverflowHandled = { event ->
if (incomingLinkQueueOverflowEvent == event) incomingLinkQueueOverflowEvent = 0L
},
)
CompositionLocalProvider(LocalAppWindowVisibility provides windowVisibility) {
NextcloudNativeApp(
services = services,
appUpdateReviewRequest = appUpdateReviewRequest,
platformCapabilityRefreshRequest = platformCapabilityRefreshRequest,
linkRequest = incomingLinkRequests.firstOrNull(),
onLinkRequestHandled = { sequence ->
if (incomingLinkRequests.firstOrNull()?.sequence == sequence) {
incomingLinkRequests.removeAt(0)
}
},
linkQueueOverflowEvent = incomingLinkQueueOverflowEvent,
onLinkQueueOverflowHandled = { event ->
if (incomingLinkQueueOverflowEvent == event) incomingLinkQueueOverflowEvent = 0L
},
)
}
}
}

override fun onStart() {
super.onStart()
windowVisibility.value = true
}

override fun onStop() {
windowVisibility.value = false
super.onStop()
}

override fun onResume() {
super.onResume()
platformCapabilityRefreshRequest += 1
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/cached-administration-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 187
pull: none
platforms: android, desktop
user-facing: yes

Hide Administration and Server apps unless the signed-in account has freshly verified access. Cache permission checks for five minutes within the session, and prevent stale responses or restored navigation from exposing admin controls.
2 changes: 1 addition & 1 deletion tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface.
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12313
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12200
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1716
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal fun SettingsScreen(
themePreference: ThemePreference,
platformCapabilityRefreshRequest: Long,
onThemePreferenceChanged: (ThemePreference) -> Unit,
canAdminister: Boolean,
onAdminApps: () -> Unit,
onOfflineCenter: () -> Unit,
onTransfers: () -> Unit,
Expand All @@ -35,9 +36,8 @@ internal fun SettingsScreen(
) {
val scope = rememberCoroutineScope()
val isDesktop = LocalNextcloudWorkspaceCapabilities.current.isDesktop
var selectedSectionName by rememberSaveable(session.serverUrl, session.loginName) {
mutableStateOf<String?>(null)
}
val selection = rememberSettingsSectionSelection(session.serverUrl, session.loginName)
var selectedSectionName by selection
val supportDrafts = rememberAccountSupportSettingsDraftState(session)
val detailStateHolder = rememberSaveableStateHolder()
var loggingOut by remember { mutableStateOf(false) }
Expand All @@ -61,6 +61,7 @@ internal fun SettingsScreen(
isDesktop = isDesktop,
hasDeviceSettings = platformCapabilities.isNotEmpty(),
hasDesktopAppSettings = hasDesktopAppSettings,
canAdminister = canAdminister,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected admin section across permission refreshes

On a two-pane Settings layout, every five-minute revalidation temporarily makes canAdminister false because the repository clears its prior result while checking. Filtering Administration here then resolves the saved selection to Account, and the existing LaunchedEffect at lines 268-271 writes that fallback into selectedSectionName; after a successful check, the administrator is therefore left on Account instead of returning to the section they were using. The same overwrite loses a restored Administration selection during the initial post-recreation check, so retain the requested selection while permission is merely unknown/checking.

AGENTS.md reference: AGENTS.md:L457-L459

Useful? React with 👍 / 👎.

)
val selectedSection = selectedSectionName?.let { restoredName ->
resolveSettingsWorkspaceSection(restoredName, visibleSections)
Expand Down Expand Up @@ -263,11 +264,7 @@ internal fun SettingsScreen(
BoxWithConstraints {
val expanded = useExpandedSettingsWorkspace(maxWidth.value.toInt())
val displayedSection = if (expanded) expandedSettingsSection(selectedSection, visibleSections) else selectedSection
LaunchedEffect(expanded, displayedSection) {
if (expanded && displayedSection != null && selectedSectionName != displayedSection.name) {
selectedSectionName = displayedSection.name
}
}
InitializeSettingsSectionSelection(selection, expanded, displayedSection)
if (expanded) {
DesktopSettingsWorkspace(
summary = SettingsWorkspaceSummary(
Expand Down Expand Up @@ -315,3 +312,21 @@ internal fun expandedSettingsSection(
selectedSection: SettingsWorkspaceSection?,
visibleSections: List<SettingsWorkspaceSection>,
): SettingsWorkspaceSection? = selectedSection ?: visibleSections.firstOrNull()

@Composable
internal fun rememberSettingsSectionSelection(serverUrl: String, loginName: String) =
rememberSaveable(serverUrl, loginName) { mutableStateOf<String?>(null) }

@Composable
internal fun InitializeSettingsSectionSelection(
selection: androidx.compose.runtime.MutableState<String?>,
expanded: Boolean,
displayedSection: SettingsWorkspaceSection?,
) {
LaunchedEffect(expanded, displayedSection, selection.value) {
// A hidden section is a display fallback, not a new user selection.
if (expanded && displayedSection != null && selection.value == null) {
selection.value = displayedSection.name
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ suspend fun loadNativeAppCatalog(
queryParameters = mapOf("details" to "true", "format" to "json"),
ocsApiRequest = true,
maximumResponseBytes = APP_CATALOG_RESPONSE_LIMIT_BYTES,
cachePolicy = NextcloudApiCachePolicy.ForceNetwork,
),
)
when (appStoreResponse.status) {
Expand Down Expand Up @@ -309,6 +310,7 @@ private fun legacyAppListRequest(filter: String): NextcloudApiRequest = Nextclou
relativePath = PROVISIONING_APPS_PATH,
queryParameters = mapOf("filter" to filter, "format" to "json"),
ocsApiRequest = true,
cachePolicy = NextcloudApiCachePolicy.ForceNetwork,
)

private fun appStoreMutationRequest(
Expand Down Expand Up @@ -379,9 +381,9 @@ private fun NextcloudApiResponse.ocsDataOrNull(): JsonElement? {
val ocs = root["ocs"] as? JsonObject ?: return null
val meta = ocs["meta"] as? JsonObject ?: return null
val statusCode = (meta["statuscode"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
if (statusCode != null && statusCode !in setOf(100, 200)) return null
if (statusCode !in setOf(100, 200)) return null
val statusValue = (meta["status"] as? JsonPrimitive)?.contentOrNull
if (statusValue != null && statusValue != "ok") return null
if (statusValue != "ok") return null
return ocs["data"]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package dev.obiente.nextcloudnative.app

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier

internal sealed interface AdminAppsContent {
data object Checking : AdminAppsContent
data class Catalog(val catalog: NativeAppCatalog) : AdminAppsContent
enum class Failure(val message: String) : AdminAppsContent {
Forbidden("This account does not have permission to manage server apps. You can return to Settings."),
Unavailable("Administrator access could not be verified. Try again, or return to Settings."),
InvalidResponse("The server returned an unexpected response while checking administrator access. Try again, or return to Settings."),
Expired("Administrator access needs to be checked again before server apps can be shown."),
}
}

internal fun adminAppsContent(state: AdministrationAccessState, canAdminister: Boolean): AdminAppsContent {
if (state.checking) return AdminAppsContent.Checking
return when (val result = state.result) {
null -> AdminAppsContent.Checking
is NativeAppCatalogResult.Available -> if (canAdminister && state.canAdminister) {
AdminAppsContent.Catalog(result.catalog)
} else {
AdminAppsContent.Failure.Expired
}
NativeAppCatalogResult.Forbidden -> AdminAppsContent.Failure.Forbidden
NativeAppCatalogResult.Unavailable -> AdminAppsContent.Failure.Unavailable
is NativeAppCatalogResult.InvalidResponse -> AdminAppsContent.Failure.InvalidResponse
}
}

@Composable
internal fun AdminAppsScreen(
access: AdministrationAccessController,
onContinueInBrowser: () -> Unit,
serverInfo: NextcloudServerInfo?,
onOpenApp: (NextcloudAppEntry) -> Unit,
onBack: () -> Unit,
) {
var search by remember { mutableStateOf("") }
var catalogFilter by remember { mutableStateOf(NativeAppCatalogFilter.All) }
var pendingLifecycleAction by remember(access.state.result) {
mutableStateOf<Pair<NativeManagedApp, NativeAppLifecycleAction>?>(null)
}
val content = adminAppsContent(access.state, access.canAdminister)

Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
ScreenHeader(
title = "Server apps",
subtitle = "Administrator app management",
onBack = onBack,
)
when (content) {
AdminAppsContent.Checking -> LoadingMessage("Checking administrator access...")
is AdminAppsContent.Catalog -> NativeAppCatalogSurface(
catalog = content.catalog,
query = search,
filter = catalogFilter,
onQueryChanged = { search = it },
onFilterChanged = { catalogFilter = it },
onOpenInstalledApp = { managed ->
serverInfo?.apps?.firstOrNull { app -> app.id == managed.id }?.let(onOpenApp)
},
onLifecycleAction = { app, action ->
if (access.canAdminister) pendingLifecycleAction = app to action
},
)
is AdminAppsContent.Failure -> ErrorMessage(
content.message,
onRetry = access.refresh,
)
}
}

pendingLifecycleAction?.takeIf { content is AdminAppsContent.Catalog }?.let { (app, action) ->
AlertDialog(
onDismissRequest = { pendingLifecycleAction = null },
title = { Text("${action.uiLabel()} ${app.name}?") },
text = {
Text(
when (action) {
NativeAppLifecycleAction.InstallAndEnable ->
"This downloads server-side code and enables the app for users."
NativeAppLifecycleAction.Enable ->
"This activates the app and may add navigation, jobs, and integrations for users."
NativeAppLifecycleAction.Disable ->
"This makes the app and its integrations unavailable until an administrator enables it again."
NativeAppLifecycleAction.Update ->
"The server may enter maintenance mode while the app package is updated. Do not interrupt it."
NativeAppLifecycleAction.Uninstall ->
"This removes the app package. App data retention depends on the app and is not guaranteed."
} + "\n\nNextcloud requires administrator password confirmation. Continue in the authenticated server administration page.",
)
},
dismissButton = {
TextButton(onClick = { pendingLifecycleAction = null }) { Text("Cancel") }
},
confirmButton = {
Button(
onClick = {
pendingLifecycleAction = null
if (access.canAdminister) {
onContinueInBrowser()
access.refresh()
}
},
colors = if (action == NativeAppLifecycleAction.Uninstall) {
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
} else {
ButtonDefaults.buttonColors()
},
) {
Text("Continue in browser")
}
},
)
}
}
Loading
Loading