From c2e688c8dcf771c15174eb50788c9d64d16b1da1 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Tue, 13 Oct 2026 16:36:13 +0200 Subject: [PATCH 1/7] chore: add openspec change for listing existing public links in Sharing NG Signed-off-by: Jorge Aguado Recio --- .../list-existing-public-links/.openspec.yaml | 2 + .../list-existing-public-links/design.md | 63 +++++++++++++ .../list-existing-public-links/proposal.md | 33 +++++++ .../specs/files-sharing/public-links/spec.md | 78 +++++++++++++++ .../list-existing-public-links/tasks.md | 94 +++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 openspec/changes/list-existing-public-links/.openspec.yaml create mode 100644 openspec/changes/list-existing-public-links/design.md create mode 100644 openspec/changes/list-existing-public-links/proposal.md create mode 100644 openspec/changes/list-existing-public-links/specs/files-sharing/public-links/spec.md create mode 100644 openspec/changes/list-existing-public-links/tasks.md diff --git a/openspec/changes/list-existing-public-links/.openspec.yaml b/openspec/changes/list-existing-public-links/.openspec.yaml new file mode 100644 index 00000000000..81264324b05 --- /dev/null +++ b/openspec/changes/list-existing-public-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/list-existing-public-links/design.md b/openspec/changes/list-existing-public-links/design.md new file mode 100644 index 00000000000..35eb5063121 --- /dev/null +++ b/openspec/changes/list-existing-public-links/design.md @@ -0,0 +1,63 @@ +## Context + +The Sharing NG flow (`Graph*`-prefixed classes under `owncloudApp/.../presentation/sharing/`) already fetches an item's permissions from the Graph API and parses them end-to-end. The whole data pipeline exists: + +- Networking (`owncloudComLibrary`): `GetRemoteGraphSharesForFileOperation(spaceId, itemId)` calls `graph/v1beta1/drives//items//permissions` and parses the response with Moshi into `PermissionsResponse` (`spaces/responses/SpacesResponse.kt`). `LinkInfoResponse` already exposes `@libre.graph.displayName`, `type`, `webUrl`. +- Data (`owncloudData`): `OCRemoteShareDataSource.getGraphShares(...)` maps `PermissionsResponse.toModel()`, already **splitting** the `value` array — entries with `grantedToV2 == null` become `OCLink`s (parsing displayName/type/webUrl/hasPassword/expiration) and populate `OCPermissions.links`. +- Domain (`owncloudDomain`): `GetGraphSharesAsyncUseCase(accountName, spaceId, itemId)` returns `OCPermissions`, whose model (`sharing/shares/model/OCPermissions.kt`) already carries `links: List`. `OCLink` (`links/model/OCLink.kt`) and `OCLinkType` (`view/edit/upload/createOnly/internal`) already exist. + +The sharing panel (`GraphShareFragment` + `GraphShareViewModel`) inflates `members_fragment.xml`, the **same layout** used by the spaces members screen (`SpaceMembersFragment`, issue #4752). That layout already contains the full public-links section (`public_links_section`, `public_links_title`, `public_links_warning`, `public_links_recycler_view`, `no_public_links_message`). The spaces screen renders links into it via `SpaceLinksAdapter` (binds `List`, inflates `public_link_item.xml`, maps type via `OCLinkType.toStringResId()`). + +What is missing is purely presentation wiring: +- `GraphShareViewModel`/`GraphShareFragment` read only `OCPermissions.roles` and `.members`; they ignore `.links`. +- The public-links section views that already exist in `GraphShareFragment`'s layout are never populated. +- The warning in `members_fragment.xml` hardcodes `share_warning_about_forwarding_space_public_links` ("...access to the space"). + +Relevant strings in `owncloudApp/src/main/res/values/strings.xml`: `share_via_link_section_title` ("Public links", header), `share_warning_about_forwarding_space_public_links` ("...the space.", current), `share_warning_about_forwarding_public_links` ("...the file/folder.", generic), `share_no_public_links` ("No public links created yet."). + +Affected flavors: all three (`original` / `mdm` / `qa`) — this is shared presentation code. + +## Goals / Non-Goals + +**Goals:** +- Render all existing public links of the selected file/folder in the sharing panel's public-links section (name, permission, expiration if present, copy-link icon). +- Reuse the existing data pipeline (`GetGraphSharesAsyncUseCase` → `OCPermissions.links`) with no changes below the presentation layer. +- Reuse the spaces links row UI (`public_link_item.xml` + `SpaceLinksAdapter`) as the layout reference. +- Replace the warning text with one that references the item type (file vs folder). + +**Non-Goals:** +- Creating, editing, or deleting public links (hide/disable those affordances in this flow). +- Displaying `hasPassword` status (kept in `OCLink`, not shown). +- Any change to networking, data, or domain layers (the pipeline already provides `links`). +- Classic WebDAV/OCS shares (`PublicShareDialogFragment` and friends are untouched). + +## Decisions + +**1. Expose `links` from `GraphShareViewModel` and render in `GraphShareFragment`.** +The ViewModel already receives the full `OCPermissions` via `_shares`. The Fragment's `observeShares()` currently reads only `.roles`/`.members`. Extend it to also pass `OCPermissions.links` to a links adapter bound to `public_links_recycler_view`, and toggle `no_public_links_message` / section visibility the way `SpaceMembersFragment.showOrHideEmptyView` / `showSpaceLinks()` already do. No new UseCase or ViewModel flow is required — the data rides on the existing shares fetch (which is already triggered on panel open), satisfying the "fetch on open" requirement with no extra network call. + +**2. Reuse the row rendering; create a thin adapter only if needed.** +Prefer reusing `SpaceLinksAdapter` + `public_link_item.xml` directly, driving it with read-only callbacks: enable only the share-link (chain icon) action and suppress edit/remove, since those are out of scope. If `SpaceLinksAdapter`'s edit/remove callbacks cannot be cleanly made optional without affecting the spaces screen, introduce a sibling `GraphLinksAdapter` (plus `GraphLinksDiffUtil`) that renders the same `public_link_item.xml` but wires only share-link. Decision is left to implementation based on how coupled `SpaceLinksAdapter`'s callbacks are; the externally observable behavior is identical either way. + +**3. Permission label mapping lives in the adapter/row binding.** +The link name is rendered directly from `link["@libre.graph.displayName"]` with no fallback (the server guarantees a non-empty name). The `OCLinkType` → label mapping (`view`→"Can view", `edit`→"Can edit", `createOnly`→"Secret file drop") reuses the existing `OCLinkType.toStringResId()` extension. Verify its mappings match the required labels; if `createOnly` does not already map to "Secret file drop", add/adjust the string mapping (not the API enum). + +**4. Expiration row shown only when present.** +`OCLink.expirationDateTime` is nullable; the row binding shows the expiration row only when it is non-null, mirroring the existing `public_link_item.xml` behavior. + +**5. Item-type-aware warning text.** +The acceptance criteria require the warning to read "...the file" or "...the folder" matching the selected item's type. The current layout hardcodes the "space" string. Replace the `public_links_warning` text at runtime in `GraphShareFragment` (not in the shared layout XML, so `SpaceMembersFragment` keeps its "space" wording). Introduce two strings — "Anyone with the link has access to the file" and "Anyone with the link has access to the folder" — and select based on `OCFile.isFolder`. The existing generic `share_warning_about_forwarding_public_links` ("...the file/folder.") is a weaker fit than the type-specific wording the acceptance criteria call for, so prefer the two type-specific strings. New strings are added only to the base `values/strings.xml`; translations go to Transifex (no translation PRs). + +**6. drive-id / item-id source unchanged.** +`GraphShareViewModel.getGraphShares()` already derives drive-id = `file.spaceId` and item-id = `file.remoteId` from the `OCFile`, emitting `IncompleteFileDataException` if either is null. No change needed. + +**Koin modules:** No new bindings expected (reuses existing ViewModel/UseCase/Repository/DataSource graph). If a dedicated `GraphLinksAdapter` is introduced it is a plain UI class, not a Koin dependency. + +**Room:** No schema changes — Graph links are fetched live and never persisted. + +## Risks / Trade-offs + +- **Shared layout coupling:** `members_fragment.xml` is shared by the spaces members screen and the sharing panel. Changing the warning text in XML would affect both; mitigated by setting the warning text programmatically in `GraphShareFragment` only. +- **Adapter reuse vs. duplication:** Reusing `SpaceLinksAdapter` risks dragging in edit/remove affordances that are out of scope; a sibling adapter avoids that at the cost of minor duplication. Either is acceptable; implementation picks the lower-risk option. +- **Permission label parity:** If `OCLinkType.toStringResId()` does not already yield exactly "Can view" / "Can edit" / "Secret file drop", the mapping must be reconciled; verify against the spaces screen to avoid regressing its labels. +- **`createOnly`/upload semantics:** The Graph API `createOnly` type must map to "Secret file drop"; confirm the existing enum/string mapping distinguishes `createOnly` from `upload` so the label is correct. \ No newline at end of file diff --git a/openspec/changes/list-existing-public-links/proposal.md b/openspec/changes/list-existing-public-links/proposal.md new file mode 100644 index 00000000000..86ed193bd36 --- /dev/null +++ b/openspec/changes/list-existing-public-links/proposal.md @@ -0,0 +1,33 @@ +## Why + +In the Sharing NG flow, the sharing panel for a file or folder does not show public links that already exist for that item. Users who created a link previously — from any client — have no way to see it, so they cannot tell which links are active. Additionally, the warning text under the `PUBLIC LINKS` header incorrectly refers to "the space" even when the shared item is a file or folder. + +## What Changes + +- The sharing panel fetches an item's permissions from the Graph API (`GET /graph/v1beta1/drives//items//permissions`, the same endpoint already used for shares) and lists all existing public links for that item. +- Only entries in the response `value` array that contain a `link` object are treated as public links; share entries without a `link` object are excluded. +- Each listed link shows: + - its name — `link["@libre.graph.displayName"]`; + - its permission label — `link.type` mapped to `Can edit` (edit), `Can view` (view), `Secret file drop` (createOnly); + - its expiration date — only when `expirationDateTime` is present; + - a chain icon on the right that shares `link.webUrl` through the Android share sheet when tapped. +- The warning text under `PUBLIC LINKS` changes from "Anyone with the link has access to the space" to reference the shared item, reading "Anyone with the link has access to the file" or "...the folder" depending on the item type. +- `hasPassword` is carried in the data model (not displayed) for use by a future edit flow. + +Out of scope: creating, editing, or deleting public links; displaying password-protection status; classic WebDAV shares. + +## Capabilities + +### New Capabilities +- `files-sharing/public-links`: Retrieving and displaying the existing public links of a file or folder in the Sharing NG panel, including each link's name, permission, expiration, and share-link action, plus the item-type-aware warning text. + +### Modified Capabilities + + +## Impact + +- **owncloudApp** (presentation): sharing panel Fragment/ViewModel render the public-links list and the item-type-aware warning; `strings.xml` warning text updated/parameterized. +- **owncloudDomain** (business): UseCase + domain model for an item's public links; repository interface for retrieving them (if not already covered by the shares flow). +- **owncloudData** (data): repository/data-source mapping of the permissions response `value` array into public-link domain models. +- **owncloudComLibrary** (networking): Moshi models for the permissions response `link` object fields, if not already present. +- No new third-party dependencies; reuses the existing Graph API permissions integration and the spaces' links UI (issue #4752) as a layout reference. \ No newline at end of file diff --git a/openspec/changes/list-existing-public-links/specs/files-sharing/public-links/spec.md b/openspec/changes/list-existing-public-links/specs/files-sharing/public-links/spec.md new file mode 100644 index 00000000000..60912d594e2 --- /dev/null +++ b/openspec/changes/list-existing-public-links/specs/files-sharing/public-links/spec.md @@ -0,0 +1,78 @@ +## Purpose + +Defines how the Sharing NG panel retrieves and displays the public links that already exist for a file or folder, so a user can see which links are active, read each link's details, and copy a link's URL. + +## ADDED Requirements + +### Requirement: Fetch existing public links when opening the sharing panel + +When the sharing panel is opened for a file or folder, the system SHALL request that item's permissions from the Graph API permissions endpoint and derive the item's public links from the response. The system SHALL treat only entries in the response `value` array that contain a `link` object as public links; entries without a `link` object SHALL be excluded. + +#### Scenario: Permissions fetched on panel open +- **WHEN** a user opens the sharing panel for a file or folder +- **THEN** the system requests the item's permissions from the Graph API permissions endpoint for that item's drive and item identifiers + +#### Scenario: Only entries with a link object are listed +- **WHEN** the permissions response contains a mix of entries, some with a `link` object and some without +- **THEN** only the entries that contain a `link` object are shown in the `PUBLIC LINKS` section +- **AND** entries without a `link` object are excluded from the public-links list + +#### Scenario: No public links exist +- **WHEN** the permissions response contains no entry with a `link` object +- **THEN** the `PUBLIC LINKS` section shows no link entries + +### Requirement: Display each public link's name + +Each public link entry SHALL display the name taken from `link["@libre.graph.displayName"]`. + +#### Scenario: Named link +- **WHEN** a public link has a `link["@libre.graph.displayName"]` +- **THEN** the entry displays that value as its name + +### Requirement: Display each public link's permission + +Each public link entry SHALL display a permission label derived from `link.type`: `edit` SHALL be shown as "Can edit", `view` SHALL be shown as "Can view", and `createOnly` SHALL be shown as "Secret file drop". + +#### Scenario: Edit permission +- **WHEN** a public link has `link.type` equal to `edit` +- **THEN** the entry displays "Can edit" as its permission + +#### Scenario: View permission +- **WHEN** a public link has `link.type` equal to `view` +- **THEN** the entry displays "Can view" as its permission + +#### Scenario: Create-only permission +- **WHEN** a public link has `link.type` equal to `createOnly` +- **THEN** the entry displays "Secret file drop" as its permission + +### Requirement: Display each public link's expiration date + +Each public link entry SHALL display an expiration date only when the response provides `expirationDateTime` for that link. When `expirationDateTime` is absent, the entry SHALL NOT display an expiration date row. + +#### Scenario: Link with expiration +- **WHEN** a public link has an `expirationDateTime` value +- **THEN** the entry displays that expiration date + +#### Scenario: Link without expiration +- **WHEN** a public link has no `expirationDateTime` value +- **THEN** the entry displays no expiration date row + +### Requirement: Share a public link's URL + +Each public link entry SHALL present a chain icon on the right side of the entry. When the user taps the chain icon, the system SHALL share that link's `link.webUrl` through the Android share sheet. + +#### Scenario: Share link URL +- **WHEN** a user taps the chain icon on a public link entry +- **THEN** the system opens the Android share sheet offering that link's `link.webUrl` + +### Requirement: Item-type-aware public-links warning text + +The warning text under the `PUBLIC LINKS` header SHALL reference the type of the shared item. When the shared item is a file, the text SHALL read "Anyone with the link has access to the file". When the shared item is a folder, the text SHALL read "Anyone with the link has access to the folder". + +#### Scenario: Warning for a file +- **WHEN** the shared item is a file +- **THEN** the warning text reads "Anyone with the link has access to the file" + +#### Scenario: Warning for a folder +- **WHEN** the shared item is a folder +- **THEN** the warning text reads "Anyone with the link has access to the folder" \ No newline at end of file diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/list-existing-public-links/tasks.md new file mode 100644 index 00000000000..1e92dc96bbf --- /dev/null +++ b/openspec/changes/list-existing-public-links/tasks.md @@ -0,0 +1,94 @@ +## 1. Verify the existing data pipeline (no code changes expected) + +- [ ] 1.1 Confirm `OCPermissions.links: List` is populated from the permissions endpoint by inspecting `OCRemoteShareDataSource.getGraphShares(...)` / `PermissionsResponse.toModel()`; verify by adding a temporary log or a debugger breakpoint in `GraphShareViewModel` showing `links` is non-empty for an item that has links on the server. +- [ ] 1.2 Confirm `OCLinkType.toStringResId()` maps `view`→"Can view", `edit`→"Can edit", `createOnly`→"Secret file drop"; verify by reading the extension and the referenced string resources. If `createOnly` does not map to "Secret file drop", note the gap for task 2.1. + +## 2. Strings (owncloudApp) + +- [ ] 2.1 Add/verify string resources in `owncloudApp/src/main/res/values/strings.xml`: two item-type warnings — "Anyone with the link has access to the file" and "Anyone with the link has access to the folder" — and the three permission labels ("Can view", "Can edit", "Secret file drop"); verify `./gradlew lintDebug` passes with no missing-resource errors. + +## 3. Render public links in the Sharing NG panel (owncloudApp) + +- [ ] 3.1 Expose `OCPermissions.links` to the UI in `GraphShareViewModel`/`GraphShareFragment.observeShares()` (currently only `.roles`/`.members` are read); verify by logging the links list reaches the Fragment when the panel opens. +- [ ] 3.2 Decide and implement the links adapter: reuse `SpaceLinksAdapter` with only the share-link (chain icon) callback enabled, or add a sibling `GraphLinksAdapter` (+ `GraphLinksDiffUtil`) rendering `public_link_item.xml`; verify the sharing panel shows one row per link with name, permission, and (when present) expiration. +- [ ] 3.3 Bind the adapter to `public_links_recycler_view` in the sharing panel and toggle `no_public_links_message` / section visibility (mirroring `SpaceMembersFragment.showOrHideEmptyView`/`showSpaceLinks`); verify an item with no links shows the empty message and an item with links shows the list. +- [ ] 3.4 Render the link name directly from `link["@libre.graph.displayName"]` (no fallback — the server guarantees a non-empty name); verify a link renders with its display name. +- [ ] 3.5 Show the expiration row only when `OCLink.expirationDateTime` is non-null; verify a link without expiration shows no expiration row and one with expiration shows the date. +- [ ] 3.6 Wire the chain icon to share `OCLink.webUrl` through the Android share sheet; verify tapping the icon opens the share sheet offering the URL. +- [ ] 3.7 Set the `public_links_warning` text programmatically in `GraphShareFragment` based on `OCFile.isFolder` (file → "...the file", folder → "...the folder"), leaving `SpaceMembersFragment`'s "space" wording unchanged; verify the file and folder sharing panels show the correct warning. +- [ ] 3.8 Suppress out-of-scope affordances (create/edit/remove link) in this flow; verify no add/edit/delete link controls are shown or actionable in the Sharing NG public-links section. + +## 4. Changelog + +- [ ] 4.1 Create `changelog/unreleased/XXXX` (placeholder PR number) with an `Enhancement` entry titled under 80 chars, body in present perfect passive tense, linking the issue (`https://github.com/owncloud/android/issues/4956`) and PR; verify the file follows the format in CLAUDE.md and `./gradlew` build is unaffected. + +## 5. Static analysis & build + +- [ ] 5.1 Run `./gradlew detekt ktlintCheck lintDebug` and `./gradlew testDebugUnitTest testMdmDebugUnitTest --continue`; verify all pass (detekt zero-tolerance). Note: no new unit tests are added, as this change touches only the presentation layer (owncloudApp), where unit tests are not established per project convention. + +## List links over sharing NG + +**Server(s)**:
+**Device(s)**:
+**Execution date**:
+**Tester**:
+ +| Test Case | Steps | Expected | Result | Comments | +|---|---|---|---|---| +| **Link list** | | | | | +| Links section visible | 1. Open the sharing panel for a file or folder | The `PUBLIC LINKS` section is shown with the warning text and either the link list or the empty state | | | +| No links — file | 1. Use a file with no public links
2. Open the sharing panel | `PUBLIC LINKS` section shows the warning "Anyone with the link has access to the file" and the message "No public links created yet." Check in both orientations | | | +| No links — folder | 1. Use a folder with no public links
2. Open the sharing panel | `PUBLIC LINKS` section shows the warning "Anyone with the link has access to the folder" and the message "No public links created yet." | | | +| Links listed — file | 1. Use a file with 2+ public links created from the web
2. Open the sharing panel | All links appear in the `PUBLIC LINKS` section, each with name, permission, and expiration date if set. Counts match the web. Check in both orientations | | | +| Links listed — folder | 1. Use a folder with 2+ public links created from the web
2. Open the sharing panel | All links appear in the `PUBLIC LINKS` section with correct details. Check in both orientations | | | +| Long link name | 1. Use an item with a link whose name is very long
2. Open the sharing panel | Long link name is displayed and ellipsized when it does not fit on one line. Check in both orientations | | | +| Permission label — view | 1. Use an item with a link of type `view`
2. Open the sharing panel | Link shows the label "Can view" | | | +| Permission label — edit | 1. Use an item with a link of type `edit`
2. Open the sharing panel | Link shows the label "Can edit" | | | +| Permission label — createOnly | 1. Use an item with a link of type `createOnly`
2. Open the sharing panel | Link shows the label "Secret file drop" | | | +| Expiration present | 1. Use an item with a link that has an expiration date
2. Open the sharing panel | The link's expiration date row is shown with the correct date | | | +| Expiration absent | 1. Use an item with a link that has no expiration date
2. Open the sharing panel | No expiration date row is shown for that link | | | +| Long list | 1. Use an item with 20+ public links
2. Open the sharing panel | All links are listed and the section scrolls correctly. Check in both orientations | | | +| Member shares excluded | 1. Use an item that has member shares but no public links
2. Open the sharing panel | No rows appear in the `PUBLIC LINKS` section; only the empty state is shown | | | +| Share link | 1. Use an item with a public link
2. Open the sharing panel
3. Tap the chain icon on any link
4. Share the URL and open it in a browser | Android share sheet opens offering the link URL; the URL resolves to the correct shared item | | | +| **External changes** | | | | | +| Add link | 1. Open the sharing panel in the app
2. From the web, add a new link to the item
3. Close and reopen the sharing panel | New link appears in the list. Newest link at the top | | | +| Edit link name | 1. Open the sharing panel in the app
2. From the web, change the name of a link
3. Close and reopen the sharing panel | Updated link name is shown | | | +| Edit permission | 1. Open the sharing panel in the app
2. From the web, change the permission of a link
3. Close and reopen the sharing panel | Updated permission label is shown | | | +| Add expiration date | 1. Open the sharing panel in the app
2. From the web, add an expiration date to a link that had none
3. Close and reopen the sharing panel | Expiration date row is now shown for that link | | | +| Change expiration date | 1. Open the sharing panel in the app
2. From the web, change the expiration date of an existing link
3. Close and reopen the sharing panel | Updated expiration date is shown | | | +| Remove expiration date | 1. Open the sharing panel in the app
2. From the web, remove the expiration date from a link
3. Close and reopen the sharing panel | Expiration date row is no longer shown for that link | | | +| Remove link | 1. Open the sharing panel in the app
2. From the web, delete one of the links
3. Close and reopen the sharing panel | Deleted link no longer appears in the list | | | +| Remove all links | 1. Open the sharing panel in the app
2. From the web, delete all public links
3. Close and reopen the sharing panel | `PUBLIC LINKS` section shows the empty state "No public links created yet." | | | +| **Errors** | | | | | +| No connection | 1. Disable the network on the device
2. Try to open the sharing panel for an item | The sharing panel does not open or shows an appropriate error; no crash | | | +| **Regression** | | | | | +| No create/edit/delete | 1. Open the sharing panel for any item
2. Inspect the `PUBLIC LINKS` section | No add, edit, or delete link controls are shown or actionable | | | +| Spaces screen | 1. Open the spaces screen
2. Open any space's members view | Links still render correctly; warning still reads "Anyone with the link has access to the space" | | | +| **Accessibility** | **One device** | | | | +| Talkback | 1. Enable Talkback on the device
2. Navigate through the `PUBLIC LINKS` section in the sharing panel | Every field (link name, permission, expiration, chain icon) is correctly announced | | | +| Keyboard | 1. Attach a physical keyboard via Bluetooth
2. Navigate through the `PUBLIC LINKS` section | Every element is reachable and no dead paths exist in navigation | | | + +### Gherkin scenario (E2E suite proposal) + +```gherkin +Feature: List existing public links in the Sharing NG panel + + Scenario: Existing public links are shown when opening the sharing panel + Given a file that has an existing public link named "Report" of type "view" with an expiration date + When I open the sharing panel for that file + Then the PUBLIC LINKS section lists a link named "Report" + And the link shows the permission "Can view" + And the link shows its expiration date + And the warning reads "Anyone with the link has access to the file" + + Scenario: Sharing a public link URL + Given the sharing panel for a file lists a public link + When I tap the chain icon on that link + Then the Android share sheet opens offering the link URL + + Scenario: Folder with no public links + Given a folder that has no public links + When I open the sharing panel for that folder + Then the PUBLIC LINKS section shows the empty message + And the warning reads "Anyone with the link has access to the folder" +``` \ No newline at end of file From cea975abb9af4890dda6cd3569d093a13efa108e Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Wed, 14 Oct 2026 14:54:45 +0200 Subject: [PATCH 2/7] feat: list existing public links in the Sharing NG panel Signed-off-by: Jorge Aguado Recio --- .../list-existing-public-links/tasks.md | 16 +++---- .../sharing/GraphShareFragment.kt | 47 ++++++++++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/list-existing-public-links/tasks.md index 1e92dc96bbf..d3e0fbf4413 100644 --- a/openspec/changes/list-existing-public-links/tasks.md +++ b/openspec/changes/list-existing-public-links/tasks.md @@ -1,19 +1,19 @@ ## 1. Verify the existing data pipeline (no code changes expected) -- [ ] 1.1 Confirm `OCPermissions.links: List` is populated from the permissions endpoint by inspecting `OCRemoteShareDataSource.getGraphShares(...)` / `PermissionsResponse.toModel()`; verify by adding a temporary log or a debugger breakpoint in `GraphShareViewModel` showing `links` is non-empty for an item that has links on the server. -- [ ] 1.2 Confirm `OCLinkType.toStringResId()` maps `view`→"Can view", `edit`→"Can edit", `createOnly`→"Secret file drop"; verify by reading the extension and the referenced string resources. If `createOnly` does not map to "Secret file drop", note the gap for task 2.1. +- [x] 1.1 Confirm `OCPermissions.links: List` is populated from the permissions endpoint by inspecting `OCRemoteShareDataSource.getGraphShares(...)` / `PermissionsResponse.toModel()`; verify by adding a temporary log or a debugger breakpoint in `GraphShareViewModel` showing `links` is non-empty for an item that has links on the server. +- [x] 1.2 Confirm `OCLinkType.toStringResId()` maps `view`→"Can view", `edit`→"Can edit", `createOnly`→"Secret file drop"; verify by reading the extension and the referenced string resources. If `createOnly` does not map to "Secret file drop", note the gap for task 2.1. ## 2. Strings (owncloudApp) -- [ ] 2.1 Add/verify string resources in `owncloudApp/src/main/res/values/strings.xml`: two item-type warnings — "Anyone with the link has access to the file" and "Anyone with the link has access to the folder" — and the three permission labels ("Can view", "Can edit", "Secret file drop"); verify `./gradlew lintDebug` passes with no missing-resource errors. +- [x] 2.1 Add/verify string resources in `owncloudApp/src/main/res/values/strings.xml`: two item-type warnings — "Anyone with the link has access to the file" and "Anyone with the link has access to the folder" — and the three permission labels ("Can view", "Can edit", "Secret file drop"); verify `./gradlew lintDebug` passes with no missing-resource errors. ## 3. Render public links in the Sharing NG panel (owncloudApp) -- [ ] 3.1 Expose `OCPermissions.links` to the UI in `GraphShareViewModel`/`GraphShareFragment.observeShares()` (currently only `.roles`/`.members` are read); verify by logging the links list reaches the Fragment when the panel opens. -- [ ] 3.2 Decide and implement the links adapter: reuse `SpaceLinksAdapter` with only the share-link (chain icon) callback enabled, or add a sibling `GraphLinksAdapter` (+ `GraphLinksDiffUtil`) rendering `public_link_item.xml`; verify the sharing panel shows one row per link with name, permission, and (when present) expiration. -- [ ] 3.3 Bind the adapter to `public_links_recycler_view` in the sharing panel and toggle `no_public_links_message` / section visibility (mirroring `SpaceMembersFragment.showOrHideEmptyView`/`showSpaceLinks`); verify an item with no links shows the empty message and an item with links shows the list. -- [ ] 3.4 Render the link name directly from `link["@libre.graph.displayName"]` (no fallback — the server guarantees a non-empty name); verify a link renders with its display name. -- [ ] 3.5 Show the expiration row only when `OCLink.expirationDateTime` is non-null; verify a link without expiration shows no expiration row and one with expiration shows the date. +- [x] 3.1 Expose `OCPermissions.links` to the UI in `GraphShareViewModel`/`GraphShareFragment.observeShares()` (currently only `.roles`/`.members` are read); verify by logging the links list reaches the Fragment when the panel opens. +- [x] 3.2 Decide and implement the links adapter: reuse `SpaceLinksAdapter` with only the share-link (chain icon) callback enabled, or add a sibling `GraphLinksAdapter` (+ `GraphLinksDiffUtil`) rendering `public_link_item.xml`; verify the sharing panel shows one row per link with name, permission, and (when present) expiration. +- [x] 3.3 Bind the adapter to `public_links_recycler_view` in the sharing panel and toggle `no_public_links_message` / section visibility (mirroring `SpaceMembersFragment.showOrHideEmptyView`/`showSpaceLinks`); verify an item with no links shows the empty message and an item with links shows the list. +- [x] 3.4 Render the link name directly from `link["@libre.graph.displayName"]` (no fallback — the server guarantees a non-empty name); verify a link renders with its display name. +- [x] 3.5 Show the expiration row only when `OCLink.expirationDateTime` is non-null; verify a link without expiration shows no expiration row and one with expiration shows the date. - [ ] 3.6 Wire the chain icon to share `OCLink.webUrl` through the Android share sheet; verify tapping the icon opens the share sheet offering the URL. - [ ] 3.7 Set the `public_links_warning` text programmatically in `GraphShareFragment` based on `OCFile.isFolder` (file → "...the file", folder → "...the folder"), leaving `SpaceMembersFragment`'s "space" wording unchanged; verify the file and folder sharing panels show the correct warning. - [ ] 3.8 Suppress out-of-scope affordances (create/edit/remove link) in this flow; verify no add/edit/delete link controls are shown or actionable in the Sharing NG public-links section. diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt index ab409648937..f9f65eab5fb 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt @@ -32,6 +32,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.owncloud.android.R import com.owncloud.android.databinding.MembersFragmentBinding import com.owncloud.android.domain.files.model.OCFile +import com.owncloud.android.domain.links.model.OCLink import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.domain.sharing.shares.model.MemberPermission import com.owncloud.android.extensions.avoidScreenshotsIfNeeded @@ -39,11 +40,17 @@ import com.owncloud.android.extensions.collectLatestLifecycleFlow import com.owncloud.android.extensions.showErrorInSnackbar import com.owncloud.android.extensions.showMessageInSnackbar import com.owncloud.android.presentation.common.UIResult +import com.owncloud.android.presentation.spaces.links.SpaceLinksAdapter +import com.owncloud.android.utils.DisplayUtils import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.core.parameter.parametersOf import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone -class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterListener { +class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterListener, SpaceLinksAdapter.SpaceLinksAdapterListener { private var _binding: MembersFragmentBinding? = null private val binding get() = _binding!! @@ -55,9 +62,11 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList } private lateinit var graphSharesAdapter: GraphSharesAdapter + private lateinit var graphLinksAdapter: SpaceLinksAdapter private var roles: List = emptyList() private var shares: List = emptyList() + private var links: List = emptyList() private var listener: GraphShareFragmentListener? = null private var canRemoveShares: Boolean = false private var canEditShares: Boolean = false @@ -78,6 +87,12 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList adapter = graphSharesAdapter } + graphLinksAdapter = SpaceLinksAdapter(this) + binding.publicLinksRecyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = graphLinksAdapter + } + binding.swipeRefreshMembers.setOnRefreshListener { graphShareViewModel.getGraphShares() } @@ -147,7 +162,9 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList uiResult.data?.let { roles = it.roles shares = it.members + links = it.links showShares() + showLinks() binding.swipeRefreshMembers.isRefreshing = false } } @@ -241,6 +258,34 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList graphSharesAdapter.setShares(shares, roles, canRemoveShares, canEditShares) } + private fun showLinks() { + val hasLinks = links.isNotEmpty() + binding.publicLinksRecyclerView.isVisible = hasLinks + binding.noPublicLinksMessage.isVisible = !hasLinks + val formatter = SimpleDateFormat(DisplayUtils.DATE_FORMAT_ISO, Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + graphLinksAdapter.setSpaceLinks( + spaceLinks = links.sortedByDescending { link -> + if (link.createdDateTime.isNotEmpty()) { + formatter.parse(link.createdDateTime) + } else { + Date(0) + } + }, + canRemoveLinks = false, + canEditLinks = false + ) + } + + override fun onCopyOrSendPublicLink(publicLinkUrl: String) { + + } + + override fun onRemovePublicLink(publicLinkId: String, publicLinkDisplayName: String) { } + + override fun onEditPublicLink(publicLink: OCLink) { } + interface GraphShareFragmentListener { fun addGraphShare(file: OCFile, accountName: String, roles: List, editMode: Boolean, selectedShare: MemberPermission?) } From 628eafa5f11b0f4343b0429c078891e8695778e8 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Wed, 14 Oct 2026 14:55:11 +0200 Subject: [PATCH 3/7] feat: share public links via the chain icon Signed-off-by: Jorge Aguado Recio --- .../list-existing-public-links/tasks.md | 2 +- .../sharing/GraphShareFragment.kt | 4 +++- .../presentation/sharing/ShareActivity.kt | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/list-existing-public-links/tasks.md index d3e0fbf4413..a528fa4258e 100644 --- a/openspec/changes/list-existing-public-links/tasks.md +++ b/openspec/changes/list-existing-public-links/tasks.md @@ -14,7 +14,7 @@ - [x] 3.3 Bind the adapter to `public_links_recycler_view` in the sharing panel and toggle `no_public_links_message` / section visibility (mirroring `SpaceMembersFragment.showOrHideEmptyView`/`showSpaceLinks`); verify an item with no links shows the empty message and an item with links shows the list. - [x] 3.4 Render the link name directly from `link["@libre.graph.displayName"]` (no fallback — the server guarantees a non-empty name); verify a link renders with its display name. - [x] 3.5 Show the expiration row only when `OCLink.expirationDateTime` is non-null; verify a link without expiration shows no expiration row and one with expiration shows the date. -- [ ] 3.6 Wire the chain icon to share `OCLink.webUrl` through the Android share sheet; verify tapping the icon opens the share sheet offering the URL. +- [x] 3.6 Wire the chain icon to share `OCLink.webUrl` through the Android share sheet; verify tapping the icon opens the share sheet offering the URL. - [ ] 3.7 Set the `public_links_warning` text programmatically in `GraphShareFragment` based on `OCFile.isFolder` (file → "...the file", folder → "...the folder"), leaving `SpaceMembersFragment`'s "space" wording unchanged; verify the file and folder sharing panels show the correct warning. - [ ] 3.8 Suppress out-of-scope affordances (create/edit/remove link) in this flow; verify no add/edit/delete link controls are shown or actionable in the Sharing NG public-links section. diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt index f9f65eab5fb..a30ec5768cb 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt @@ -279,7 +279,8 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList } override fun onCopyOrSendPublicLink(publicLinkUrl: String) { - + val file = requireArguments().getParcelable(ARG_FILE) + listener?.copyOrSendPublicLink(publicLinkUrl, file?.fileName.orEmpty()) } override fun onRemovePublicLink(publicLinkId: String, publicLinkDisplayName: String) { } @@ -288,6 +289,7 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList interface GraphShareFragmentListener { fun addGraphShare(file: OCFile, accountName: String, roles: List, editMode: Boolean, selectedShare: MemberPermission?) + fun copyOrSendPublicLink(publicLinkUrl: String, fileName: String) } companion object { diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt index 8134535c716..7df4fb58717 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt @@ -27,6 +27,7 @@ package com.owncloud.android.presentation.sharing +import android.accounts.AccountManager import android.app.SearchManager import android.content.Intent import android.os.Bundle @@ -49,6 +50,7 @@ import com.owncloud.android.extensions.adaptInfiniteEdges import com.owncloud.android.extensions.showErrorInSnackbar import com.owncloud.android.lib.resources.shares.RemoteShare import com.owncloud.android.presentation.capabilities.CapabilityViewModel +import com.owncloud.android.presentation.common.ShareSheetHelper import com.owncloud.android.presentation.common.UIResult import com.owncloud.android.presentation.sharing.sharees.EditPrivateOcsShareFragment import com.owncloud.android.presentation.sharing.sharees.SearchOcsShareesFragment @@ -128,6 +130,24 @@ class ShareActivity : FileActivity(), OcsShareFragmentListener, GraphShareFragme } } + override fun copyOrSendPublicLink(publicLinkUrl: String, fileName: String) { + val displayName = AccountManager.get(this).getUserData(account, KEY_DISPLAY_NAME) + + val intentToShareLink = Intent(Intent.ACTION_SEND).apply { + type = TYPE_PLAIN + putExtra(Intent.EXTRA_TEXT, publicLinkUrl) + putExtra(Intent.EXTRA_SUBJECT, getString(R.string.subject_user_shared_with_you, displayName, fileName)) + } + + val shareSheetIntent = ShareSheetHelper().getShareSheetIntent( + intent = intentToShareLink, + context = this, + title = R.string.activity_chooser_title, + packagesToExclude = arrayOf(packageName) + ) + startActivity(shareSheetIntent) + } + private fun setupToolbar(root: View) { adaptInfiniteEdges(root) setupStandardToolbar(title = null, displayHomeAsUpEnabled = true, homeButtonEnabled = true, displayShowTitleEnabled = true) @@ -395,5 +415,7 @@ class ShareActivity : FileActivity(), OcsShareFragmentListener, GraphShareFragme const val TAG_EDIT_SHARE_FRAGMENT = "EDIT_SHARE_FRAGMENT" const val TAG_PUBLIC_SHARE_DIALOG_FRAGMENT = "PUBLIC_SHARE_DIALOG_FRAGMENT" const val TAG_REMOVE_SHARE_DIALOG_FRAGMENT = "REMOVE_SHARE_DIALOG_FRAGMENT" + private const val TYPE_PLAIN = "text/plain" + private const val KEY_DISPLAY_NAME = "oc_display_name" } } From fdd4a6f71f0a14b0f1d96ae7c090082702ec1dbb Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Wed, 14 Oct 2026 14:55:31 +0200 Subject: [PATCH 4/7] feat: show item-type-aware warning for public links Signed-off-by: Jorge Aguado Recio --- openspec/changes/list-existing-public-links/tasks.md | 4 ++-- .../android/presentation/sharing/GraphShareFragment.kt | 7 +++++++ owncloudApp/src/main/res/values/strings.xml | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/list-existing-public-links/tasks.md index a528fa4258e..741442620bc 100644 --- a/openspec/changes/list-existing-public-links/tasks.md +++ b/openspec/changes/list-existing-public-links/tasks.md @@ -15,8 +15,8 @@ - [x] 3.4 Render the link name directly from `link["@libre.graph.displayName"]` (no fallback — the server guarantees a non-empty name); verify a link renders with its display name. - [x] 3.5 Show the expiration row only when `OCLink.expirationDateTime` is non-null; verify a link without expiration shows no expiration row and one with expiration shows the date. - [x] 3.6 Wire the chain icon to share `OCLink.webUrl` through the Android share sheet; verify tapping the icon opens the share sheet offering the URL. -- [ ] 3.7 Set the `public_links_warning` text programmatically in `GraphShareFragment` based on `OCFile.isFolder` (file → "...the file", folder → "...the folder"), leaving `SpaceMembersFragment`'s "space" wording unchanged; verify the file and folder sharing panels show the correct warning. -- [ ] 3.8 Suppress out-of-scope affordances (create/edit/remove link) in this flow; verify no add/edit/delete link controls are shown or actionable in the Sharing NG public-links section. +- [x] 3.7 Set the `public_links_warning` text programmatically in `GraphShareFragment` based on `OCFile.isFolder` (file → "...the file", folder → "...the folder"), leaving `SpaceMembersFragment`'s "space" wording unchanged; verify the file and folder sharing panels show the correct warning. +- [x] 3.8 Suppress out-of-scope affordances (create/edit/remove link) in this flow; verify no add/edit/delete link controls are shown or actionable in the Sharing NG public-links section. ## 4. Changelog diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt index a30ec5768cb..89ef30fe772 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt @@ -99,6 +99,13 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList val file = requireArguments().getParcelable(ARG_FILE) val accountName = requireArguments().getString(ARG_ACCOUNT_NAME) + binding.publicLinksWarning.text = getString( + if (file?.isFolder == true) { + R.string.share_warning_about_forwarding_folder_public_links + } else { + R.string.share_warning_about_forwarding_file_public_links + } + ) binding.addMemberButton.isVisible = file?.hasResharePermission ?: false binding.addMemberButton.contentDescription = getString(R.string.content_description_add_share) binding.addMemberButton.setOnClickListener { diff --git a/owncloudApp/src/main/res/values/strings.xml b/owncloudApp/src/main/res/values/strings.xml index e87f30e3983..3d0e5e94d92 100644 --- a/owncloudApp/src/main/res/values/strings.xml +++ b/owncloudApp/src/main/res/values/strings.xml @@ -559,6 +559,8 @@ This share has no link. Anyone with the link has access to the file/folder. Anyone with the link has access to the space. + Anyone with the link has access to the file. + Anyone with the link has access to the folder. ***** Password * Expiration * From 07287f336111f3246387644095e3d2020b470751 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Wed, 14 Oct 2026 14:55:53 +0200 Subject: [PATCH 5/7] chore: add calens file Signed-off-by: Jorge Aguado Recio --- changelog/unreleased/5015 | 7 +++++++ openspec/changes/list-existing-public-links/tasks.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/5015 diff --git a/changelog/unreleased/5015 b/changelog/unreleased/5015 new file mode 100644 index 00000000000..13387afea25 --- /dev/null +++ b/changelog/unreleased/5015 @@ -0,0 +1,7 @@ +Enhancement: List existing public links on oCIS servers + +The existing public links of a file or folder have been listed, showing each link's name, +permission and expiration date, with a chain icon to share the link's URL. + +https://github.com/owncloud/android/issues/4956 +https://github.com/owncloud/android/pull/5015 diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/list-existing-public-links/tasks.md index 741442620bc..d1e8b8a2e6c 100644 --- a/openspec/changes/list-existing-public-links/tasks.md +++ b/openspec/changes/list-existing-public-links/tasks.md @@ -20,7 +20,7 @@ ## 4. Changelog -- [ ] 4.1 Create `changelog/unreleased/XXXX` (placeholder PR number) with an `Enhancement` entry titled under 80 chars, body in present perfect passive tense, linking the issue (`https://github.com/owncloud/android/issues/4956`) and PR; verify the file follows the format in CLAUDE.md and `./gradlew` build is unaffected. +- [x] 4.1 Create `changelog/unreleased/XXXX` (placeholder PR number) with an `Enhancement` entry titled under 80 chars, body in present perfect passive tense, linking the issue (`https://github.com/owncloud/android/issues/4956`) and PR; verify the file follows the format in CLAUDE.md and `./gradlew` build is unaffected. ## 5. Static analysis & build From f36b371c92c79d596524fc40f5ce7c217153cb51 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Wed, 14 Oct 2026 14:56:11 +0200 Subject: [PATCH 6/7] refactor: rename space links UI to shared PublicLinks package Signed-off-by: Jorge Aguado Recio --- .../list-existing-public-links/design.md | 8 +-- .../list-existing-public-links/proposal.md | 2 +- .../list-existing-public-links/tasks.md | 2 +- .../PublicLinksAdapter.kt} | 50 +++++++++---------- .../PublicLinksDiffUtil.kt} | 4 +- .../sharing/GraphShareFragment.kt | 12 ++--- .../spaces/members/SpaceMembersFragment.kt | 12 ++--- 7 files changed, 45 insertions(+), 45 deletions(-) rename owncloudApp/src/main/java/com/owncloud/android/presentation/{spaces/links/SpaceLinksAdapter.kt => links/PublicLinksAdapter.kt} (68%) rename owncloudApp/src/main/java/com/owncloud/android/presentation/{spaces/links/SpaceLinksDiffUtil.kt => links/PublicLinksDiffUtil.kt} (95%) diff --git a/openspec/changes/list-existing-public-links/design.md b/openspec/changes/list-existing-public-links/design.md index 35eb5063121..e4a92b9f71c 100644 --- a/openspec/changes/list-existing-public-links/design.md +++ b/openspec/changes/list-existing-public-links/design.md @@ -6,7 +6,7 @@ The Sharing NG flow (`Graph*`-prefixed classes under `owncloudApp/.../presentati - Data (`owncloudData`): `OCRemoteShareDataSource.getGraphShares(...)` maps `PermissionsResponse.toModel()`, already **splitting** the `value` array — entries with `grantedToV2 == null` become `OCLink`s (parsing displayName/type/webUrl/hasPassword/expiration) and populate `OCPermissions.links`. - Domain (`owncloudDomain`): `GetGraphSharesAsyncUseCase(accountName, spaceId, itemId)` returns `OCPermissions`, whose model (`sharing/shares/model/OCPermissions.kt`) already carries `links: List`. `OCLink` (`links/model/OCLink.kt`) and `OCLinkType` (`view/edit/upload/createOnly/internal`) already exist. -The sharing panel (`GraphShareFragment` + `GraphShareViewModel`) inflates `members_fragment.xml`, the **same layout** used by the spaces members screen (`SpaceMembersFragment`, issue #4752). That layout already contains the full public-links section (`public_links_section`, `public_links_title`, `public_links_warning`, `public_links_recycler_view`, `no_public_links_message`). The spaces screen renders links into it via `SpaceLinksAdapter` (binds `List`, inflates `public_link_item.xml`, maps type via `OCLinkType.toStringResId()`). +The sharing panel (`GraphShareFragment` + `GraphShareViewModel`) inflates `members_fragment.xml`, the **same layout** used by the spaces members screen (`SpaceMembersFragment`, issue #4752). That layout already contains the full public-links section (`public_links_section`, `public_links_title`, `public_links_warning`, `public_links_recycler_view`, `no_public_links_message`). The spaces screen renders links into it via `SpaceLinksAdapter` (renamed to `PublicLinksAdapter` under a shared `presentation/links` package by this change — see Decision 2) (binds `List`, inflates `public_link_item.xml`, maps type via `OCLinkType.toStringResId()`). What is missing is purely presentation wiring: - `GraphShareViewModel`/`GraphShareFragment` read only `OCPermissions.roles` and `.members`; they ignore `.links`. @@ -36,8 +36,8 @@ Affected flavors: all three (`original` / `mdm` / `qa`) — this is shared prese **1. Expose `links` from `GraphShareViewModel` and render in `GraphShareFragment`.** The ViewModel already receives the full `OCPermissions` via `_shares`. The Fragment's `observeShares()` currently reads only `.roles`/`.members`. Extend it to also pass `OCPermissions.links` to a links adapter bound to `public_links_recycler_view`, and toggle `no_public_links_message` / section visibility the way `SpaceMembersFragment.showOrHideEmptyView` / `showSpaceLinks()` already do. No new UseCase or ViewModel flow is required — the data rides on the existing shares fetch (which is already triggered on panel open), satisfying the "fetch on open" requirement with no extra network call. -**2. Reuse the row rendering; create a thin adapter only if needed.** -Prefer reusing `SpaceLinksAdapter` + `public_link_item.xml` directly, driving it with read-only callbacks: enable only the share-link (chain icon) action and suppress edit/remove, since those are out of scope. If `SpaceLinksAdapter`'s edit/remove callbacks cannot be cleanly made optional without affecting the spaces screen, introduce a sibling `GraphLinksAdapter` (plus `GraphLinksDiffUtil`) that renders the same `public_link_item.xml` but wires only share-link. Decision is left to implementation based on how coupled `SpaceLinksAdapter`'s callbacks are; the externally observable behavior is identical either way. +**2. Rename the links row UI to a shared `PublicLinks…` package and reuse it.** +The spaces links row UI (`SpaceLinksAdapter` + `SpaceLinksDiffUtil`, with nested `SpaceLinksViewHolder` / `SpaceLinksAdapterListener`, previously under `presentation/spaces/links`) is renamed to `PublicLinksAdapter` + `PublicLinksDiffUtil` (`PublicLinksViewHolder` / `PublicLinksAdapterListener`; the `setSpaceLinks`/`spaceLinks` members become `setPublicLinks`/`publicLinks`) and moved to a shared top-level package `presentation/links`, since it is now used by both the spaces members screen and the Sharing NG panel. `GraphShareFragment` reuses `PublicLinksAdapter` read-only: it enables only the share-link (chain icon) action and suppresses edit/remove, which are out of scope. `SpaceMembersFragment` is updated to the new package and names; its behavior is unchanged. (`SpaceLinksViewModel`, part of the spaces add-link flow, is not used here and is left untouched.) **3. Permission label mapping lives in the adapter/row binding.** The link name is rendered directly from `link["@libre.graph.displayName"]` with no fallback (the server guarantees a non-empty name). The `OCLinkType` → label mapping (`view`→"Can view", `edit`→"Can edit", `createOnly`→"Secret file drop") reuses the existing `OCLinkType.toStringResId()` extension. Verify its mappings match the required labels; if `createOnly` does not already map to "Secret file drop", add/adjust the string mapping (not the API enum). @@ -58,6 +58,6 @@ The acceptance criteria require the warning to read "...the file" or "...the fol ## Risks / Trade-offs - **Shared layout coupling:** `members_fragment.xml` is shared by the spaces members screen and the sharing panel. Changing the warning text in XML would affect both; mitigated by setting the warning text programmatically in `GraphShareFragment` only. -- **Adapter reuse vs. duplication:** Reusing `SpaceLinksAdapter` risks dragging in edit/remove affordances that are out of scope; a sibling adapter avoids that at the cost of minor duplication. Either is acceptable; implementation picks the lower-risk option. +- **Shared adapter rename reaches the spaces screen:** renaming `SpaceLinks…`→`PublicLinks…` and moving the package means `SpaceMembersFragment` (issue #4752) must be updated to the new names; mitigated by a pure rename/move with no behavior change, verified by the spaces-screen regression test. - **Permission label parity:** If `OCLinkType.toStringResId()` does not already yield exactly "Can view" / "Can edit" / "Secret file drop", the mapping must be reconciled; verify against the spaces screen to avoid regressing its labels. - **`createOnly`/upload semantics:** The Graph API `createOnly` type must map to "Secret file drop"; confirm the existing enum/string mapping distinguishes `createOnly` from `upload` so the label is correct. \ No newline at end of file diff --git a/openspec/changes/list-existing-public-links/proposal.md b/openspec/changes/list-existing-public-links/proposal.md index 86ed193bd36..937bcb06ac0 100644 --- a/openspec/changes/list-existing-public-links/proposal.md +++ b/openspec/changes/list-existing-public-links/proposal.md @@ -30,4 +30,4 @@ Out of scope: creating, editing, or deleting public links; displaying password-p - **owncloudDomain** (business): UseCase + domain model for an item's public links; repository interface for retrieving them (if not already covered by the shares flow). - **owncloudData** (data): repository/data-source mapping of the permissions response `value` array into public-link domain models. - **owncloudComLibrary** (networking): Moshi models for the permissions response `link` object fields, if not already present. -- No new third-party dependencies; reuses the existing Graph API permissions integration and the spaces' links UI (issue #4752) as a layout reference. \ No newline at end of file +- No new third-party dependencies; reuses the existing Graph API permissions integration and the spaces' links row UI (issue #4752), which is renamed to `PublicLinksAdapter`/`PublicLinksDiffUtil` and moved to a shared `presentation/links` package so both screens share it. \ No newline at end of file diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/list-existing-public-links/tasks.md index d1e8b8a2e6c..d67b2752273 100644 --- a/openspec/changes/list-existing-public-links/tasks.md +++ b/openspec/changes/list-existing-public-links/tasks.md @@ -10,7 +10,7 @@ ## 3. Render public links in the Sharing NG panel (owncloudApp) - [x] 3.1 Expose `OCPermissions.links` to the UI in `GraphShareViewModel`/`GraphShareFragment.observeShares()` (currently only `.roles`/`.members` are read); verify by logging the links list reaches the Fragment when the panel opens. -- [x] 3.2 Decide and implement the links adapter: reuse `SpaceLinksAdapter` with only the share-link (chain icon) callback enabled, or add a sibling `GraphLinksAdapter` (+ `GraphLinksDiffUtil`) rendering `public_link_item.xml`; verify the sharing panel shows one row per link with name, permission, and (when present) expiration. +- [x] 3.2 Refactor the shared links row UI: rename `SpaceLinksAdapter`→`PublicLinksAdapter` and `SpaceLinksDiffUtil`→`PublicLinksDiffUtil` (nested `PublicLinksViewHolder`/`PublicLinksAdapterListener`; `setSpaceLinks`/`spaceLinks`→`setPublicLinks`/`publicLinks`), move them to a new shared package `com.owncloud.android.presentation.links`, and update both `GraphShareFragment` and `SpaceMembersFragment` to the new names/package; verify the sharing panel shows one row per link (name, permission, expiration when present) and the spaces screen still renders links unchanged. - [x] 3.3 Bind the adapter to `public_links_recycler_view` in the sharing panel and toggle `no_public_links_message` / section visibility (mirroring `SpaceMembersFragment.showOrHideEmptyView`/`showSpaceLinks`); verify an item with no links shows the empty message and an item with links shows the list. - [x] 3.4 Render the link name directly from `link["@libre.graph.displayName"]` (no fallback — the server guarantees a non-empty name); verify a link renders with its display name. - [x] 3.5 Show the expiration row only when `OCLink.expirationDateTime` is non-null; verify a link without expiration shows no expiration row and one with expiration shows the date. diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/links/SpaceLinksAdapter.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/links/PublicLinksAdapter.kt similarity index 68% rename from owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/links/SpaceLinksAdapter.kt rename to owncloudApp/src/main/java/com/owncloud/android/presentation/links/PublicLinksAdapter.kt index 2b03d7ee749..4818eeb73a7 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/links/SpaceLinksAdapter.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/links/PublicLinksAdapter.kt @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.owncloud.android.presentation.spaces.links +package com.owncloud.android.presentation.links import android.view.LayoutInflater import android.view.View @@ -33,80 +33,80 @@ import com.owncloud.android.extensions.toStringResId import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.PreferenceUtils -class SpaceLinksAdapter( - private val listener: SpaceLinksAdapterListener -): RecyclerView.Adapter() { +class PublicLinksAdapter( + private val listener: PublicLinksAdapterListener +): RecyclerView.Adapter() { - private var spaceLinks: List = emptyList() + private var publicLinks: List = emptyList() private var canRemoveLinks = false private var canEditLinks = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpaceLinksViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PublicLinksViewHolder { val inflater = LayoutInflater.from(parent.context) val view = inflater.inflate(R.layout.public_link_item, parent, false) view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context) - return SpaceLinksViewHolder(view) + return PublicLinksViewHolder(view) } - override fun onBindViewHolder(holder: SpaceLinksViewHolder, position: Int) { - val spaceLink = spaceLinks[position] + override fun onBindViewHolder(holder: PublicLinksViewHolder, position: Int) { + val publicLink = publicLinks[position] holder.binding.apply { - publicLinkDisplayName.text = spaceLink.displayName - publicLinkType.text = holder.itemView.context.getString(spaceLink.type.toStringResId()) + publicLinkDisplayName.text = publicLink.displayName + publicLinkType.text = holder.itemView.context.getString(publicLink.type.toStringResId()) - val hasExpirationDate = spaceLink.expirationDateTime != null + val hasExpirationDate = publicLink.expirationDateTime != null expirationCalendarIcon.isVisible = hasExpirationDate expirationDate.isVisible = hasExpirationDate if (hasExpirationDate) { expirationDate.apply { - text = DisplayUtils.displayDateToHumanReadable(spaceLink.expirationDateTime) + text = DisplayUtils.displayDateToHumanReadable(publicLink.expirationDateTime) contentDescription = holder.itemView.context.getString(R.string.content_description_member_expiration_date, expirationDate.text) } } copyPublicLinkButton.apply { - contentDescription = holder.itemView.context.getString(R.string.content_description_get_public_link, spaceLink.displayName) + contentDescription = holder.itemView.context.getString(R.string.content_description_get_public_link, publicLink.displayName) setOnClickListener { - listener.onCopyOrSendPublicLink(spaceLink.webUrl) + listener.onCopyOrSendPublicLink(publicLink.webUrl) } } removePublicLinkButton.apply { - contentDescription = holder.itemView.context.getString(R.string.content_description_delete_public_link, spaceLink.displayName) + contentDescription = holder.itemView.context.getString(R.string.content_description_delete_public_link, publicLink.displayName) isVisible = canRemoveLinks setOnClickListener { - listener.onRemovePublicLink(spaceLink.id, spaceLink.displayName) + listener.onRemovePublicLink(publicLink.id, publicLink.displayName) } } editPublicLinkButton.apply { - contentDescription = holder.itemView.context.getString(R.string.content_description_edit_public_link, spaceLink.displayName) + contentDescription = holder.itemView.context.getString(R.string.content_description_edit_public_link, publicLink.displayName) isVisible = canEditLinks setOnClickListener { - listener.onEditPublicLink(spaceLink) + listener.onEditPublicLink(publicLink) } } } } - override fun getItemCount(): Int = spaceLinks.size + override fun getItemCount(): Int = publicLinks.size - fun setSpaceLinks(spaceLinks: List, canRemoveLinks: Boolean, canEditLinks: Boolean) { + fun setPublicLinks(publicLinks: List, canRemoveLinks: Boolean, canEditLinks: Boolean) { val hasUserPermissionsChanged = this.canEditLinks != canEditLinks || this.canRemoveLinks != canRemoveLinks this.canRemoveLinks = canRemoveLinks this.canEditLinks = canEditLinks - val diffCallback = SpaceLinksDiffUtil(this.spaceLinks, spaceLinks, hasUserPermissionsChanged) + val diffCallback = PublicLinksDiffUtil(this.publicLinks, publicLinks, hasUserPermissionsChanged) val diffResult = DiffUtil.calculateDiff(diffCallback) - this.spaceLinks = spaceLinks + this.publicLinks = publicLinks diffResult.dispatchUpdatesTo(this) } - class SpaceLinksViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + class PublicLinksViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val binding = PublicLinkItemBinding.bind(itemView) } - interface SpaceLinksAdapterListener { + interface PublicLinksAdapterListener { fun onCopyOrSendPublicLink(publicLinkUrl: String) fun onRemovePublicLink(publicLinkId: String, publicLinkDisplayName: String) fun onEditPublicLink(publicLink: OCLink) diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/links/SpaceLinksDiffUtil.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/links/PublicLinksDiffUtil.kt similarity index 95% rename from owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/links/SpaceLinksDiffUtil.kt rename to owncloudApp/src/main/java/com/owncloud/android/presentation/links/PublicLinksDiffUtil.kt index 39bb44d8880..4090c83c926 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/links/SpaceLinksDiffUtil.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/links/PublicLinksDiffUtil.kt @@ -18,12 +18,12 @@ * along with this program. If not, see . */ -package com.owncloud.android.presentation.spaces.links +package com.owncloud.android.presentation.links import androidx.recyclerview.widget.DiffUtil import com.owncloud.android.domain.links.model.OCLink -class SpaceLinksDiffUtil( +class PublicLinksDiffUtil( private val oldList: List, private val newList: List, private val hasUserPermissionsChanged: Boolean diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt index 89ef30fe772..1ce20d4ed93 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt @@ -40,7 +40,7 @@ import com.owncloud.android.extensions.collectLatestLifecycleFlow import com.owncloud.android.extensions.showErrorInSnackbar import com.owncloud.android.extensions.showMessageInSnackbar import com.owncloud.android.presentation.common.UIResult -import com.owncloud.android.presentation.spaces.links.SpaceLinksAdapter +import com.owncloud.android.presentation.links.PublicLinksAdapter import com.owncloud.android.utils.DisplayUtils import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.core.parameter.parametersOf @@ -50,7 +50,7 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterListener, SpaceLinksAdapter.SpaceLinksAdapterListener { +class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterListener, PublicLinksAdapter.PublicLinksAdapterListener { private var _binding: MembersFragmentBinding? = null private val binding get() = _binding!! @@ -62,7 +62,7 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList } private lateinit var graphSharesAdapter: GraphSharesAdapter - private lateinit var graphLinksAdapter: SpaceLinksAdapter + private lateinit var graphLinksAdapter: PublicLinksAdapter private var roles: List = emptyList() private var shares: List = emptyList() @@ -87,7 +87,7 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList adapter = graphSharesAdapter } - graphLinksAdapter = SpaceLinksAdapter(this) + graphLinksAdapter = PublicLinksAdapter(this) binding.publicLinksRecyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) adapter = graphLinksAdapter @@ -272,8 +272,8 @@ class GraphShareFragment : Fragment(), GraphSharesAdapter.GraphSharesAdapterList val formatter = SimpleDateFormat(DisplayUtils.DATE_FORMAT_ISO, Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") } - graphLinksAdapter.setSpaceLinks( - spaceLinks = links.sortedByDescending { link -> + graphLinksAdapter.setPublicLinks( + publicLinks = links.sortedByDescending { link -> if (link.createdDateTime.isNotEmpty()) { formatter.parse(link.createdDateTime) } else { diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt index 1d0b2540093..6c8822d3b5e 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt @@ -43,7 +43,7 @@ import com.owncloud.android.extensions.showAlertDialog import com.owncloud.android.extensions.showErrorInSnackbar import com.owncloud.android.extensions.showMessageInSnackbar import com.owncloud.android.presentation.common.UIResult -import com.owncloud.android.presentation.spaces.links.SpaceLinksAdapter +import com.owncloud.android.presentation.links.PublicLinksAdapter import com.owncloud.android.presentation.spaces.links.SpaceLinksViewModel import com.owncloud.android.utils.DisplayUtils import org.koin.androidx.viewmodel.ext.android.activityViewModel @@ -54,7 +54,7 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class SpaceMembersFragment : Fragment(), SpaceMembersAdapter.SpaceMembersAdapterListener, SpaceLinksAdapter.SpaceLinksAdapterListener { +class SpaceMembersFragment : Fragment(), SpaceMembersAdapter.SpaceMembersAdapterListener, PublicLinksAdapter.PublicLinksAdapterListener { private var _binding: MembersFragmentBinding? = null private val binding get() = _binding!! @@ -72,7 +72,7 @@ class SpaceMembersFragment : Fragment(), SpaceMembersAdapter.SpaceMembersAdapter } private lateinit var spaceMembersAdapter: SpaceMembersAdapter - private lateinit var spaceLinksAdapter: SpaceLinksAdapter + private lateinit var spaceLinksAdapter: PublicLinksAdapter private lateinit var currentSpace: OCSpace private var roles: List = emptyList() @@ -104,7 +104,7 @@ class SpaceMembersFragment : Fragment(), SpaceMembersAdapter.SpaceMembersAdapter adapter = spaceMembersAdapter } - spaceLinksAdapter = SpaceLinksAdapter(this) + spaceLinksAdapter = PublicLinksAdapter(this) binding.publicLinksRecyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) adapter = spaceLinksAdapter @@ -446,8 +446,8 @@ class SpaceMembersFragment : Fragment(), SpaceMembersAdapter.SpaceMembersAdapter val formatter = SimpleDateFormat(DisplayUtils.DATE_FORMAT_ISO, Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") } - spaceLinksAdapter.setSpaceLinks( - spaceLinks = spaceLinks.sortedByDescending { spaceLink -> + spaceLinksAdapter.setPublicLinks( + publicLinks = spaceLinks.sortedByDescending { spaceLink -> if (spaceLink.createdDateTime.isNotEmpty()) { formatter.parse(spaceLink.createdDateTime) } else { From fa5afacf9462cca77ffd275538fba37d8c0422c4 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 15 Oct 2026 13:34:47 +0200 Subject: [PATCH 7/7] chore: archive list-existing-public-links change Signed-off-by: Jorge Aguado Recio --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/files-sharing/public-links/spec.md | 0 .../tasks.md | 0 .../specs/files-sharing/public-links/spec.md | 78 +++++++++++++++++++ 6 files changed, 78 insertions(+) rename openspec/changes/{list-existing-public-links => archive/2026-10-15-list-existing-public-links}/.openspec.yaml (100%) rename openspec/changes/{list-existing-public-links => archive/2026-10-15-list-existing-public-links}/design.md (100%) rename openspec/changes/{list-existing-public-links => archive/2026-10-15-list-existing-public-links}/proposal.md (100%) rename openspec/changes/{list-existing-public-links => archive/2026-10-15-list-existing-public-links}/specs/files-sharing/public-links/spec.md (100%) rename openspec/changes/{list-existing-public-links => archive/2026-10-15-list-existing-public-links}/tasks.md (100%) create mode 100644 openspec/specs/files-sharing/public-links/spec.md diff --git a/openspec/changes/list-existing-public-links/.openspec.yaml b/openspec/changes/archive/2026-10-15-list-existing-public-links/.openspec.yaml similarity index 100% rename from openspec/changes/list-existing-public-links/.openspec.yaml rename to openspec/changes/archive/2026-10-15-list-existing-public-links/.openspec.yaml diff --git a/openspec/changes/list-existing-public-links/design.md b/openspec/changes/archive/2026-10-15-list-existing-public-links/design.md similarity index 100% rename from openspec/changes/list-existing-public-links/design.md rename to openspec/changes/archive/2026-10-15-list-existing-public-links/design.md diff --git a/openspec/changes/list-existing-public-links/proposal.md b/openspec/changes/archive/2026-10-15-list-existing-public-links/proposal.md similarity index 100% rename from openspec/changes/list-existing-public-links/proposal.md rename to openspec/changes/archive/2026-10-15-list-existing-public-links/proposal.md diff --git a/openspec/changes/list-existing-public-links/specs/files-sharing/public-links/spec.md b/openspec/changes/archive/2026-10-15-list-existing-public-links/specs/files-sharing/public-links/spec.md similarity index 100% rename from openspec/changes/list-existing-public-links/specs/files-sharing/public-links/spec.md rename to openspec/changes/archive/2026-10-15-list-existing-public-links/specs/files-sharing/public-links/spec.md diff --git a/openspec/changes/list-existing-public-links/tasks.md b/openspec/changes/archive/2026-10-15-list-existing-public-links/tasks.md similarity index 100% rename from openspec/changes/list-existing-public-links/tasks.md rename to openspec/changes/archive/2026-10-15-list-existing-public-links/tasks.md diff --git a/openspec/specs/files-sharing/public-links/spec.md b/openspec/specs/files-sharing/public-links/spec.md new file mode 100644 index 00000000000..6852fafcd10 --- /dev/null +++ b/openspec/specs/files-sharing/public-links/spec.md @@ -0,0 +1,78 @@ +## Purpose + +Defines how the Sharing NG panel retrieves and displays the public links that already exist for a file or folder, so a user can see which links are active, read each link's details, and copy a link's URL. + +## Requirements + +### Requirement: Fetch existing public links when opening the sharing panel + +When the sharing panel is opened for a file or folder, the system SHALL request that item's permissions from the Graph API permissions endpoint and derive the item's public links from the response. The system SHALL treat only entries in the response `value` array that contain a `link` object as public links; entries without a `link` object SHALL be excluded. + +#### Scenario: Permissions fetched on panel open +- **WHEN** a user opens the sharing panel for a file or folder +- **THEN** the system requests the item's permissions from the Graph API permissions endpoint for that item's drive and item identifiers + +#### Scenario: Only entries with a link object are listed +- **WHEN** the permissions response contains a mix of entries, some with a `link` object and some without +- **THEN** only the entries that contain a `link` object are shown in the `PUBLIC LINKS` section +- **AND** entries without a `link` object are excluded from the public-links list + +#### Scenario: No public links exist +- **WHEN** the permissions response contains no entry with a `link` object +- **THEN** the `PUBLIC LINKS` section shows no link entries + +### Requirement: Display each public link's name + +Each public link entry SHALL display the name taken from `link["@libre.graph.displayName"]`. + +#### Scenario: Named link +- **WHEN** a public link has a `link["@libre.graph.displayName"]` +- **THEN** the entry displays that value as its name + +### Requirement: Display each public link's permission + +Each public link entry SHALL display a permission label derived from `link.type`: `edit` SHALL be shown as "Can edit", `view` SHALL be shown as "Can view", and `createOnly` SHALL be shown as "Secret file drop". + +#### Scenario: Edit permission +- **WHEN** a public link has `link.type` equal to `edit` +- **THEN** the entry displays "Can edit" as its permission + +#### Scenario: View permission +- **WHEN** a public link has `link.type` equal to `view` +- **THEN** the entry displays "Can view" as its permission + +#### Scenario: Create-only permission +- **WHEN** a public link has `link.type` equal to `createOnly` +- **THEN** the entry displays "Secret file drop" as its permission + +### Requirement: Display each public link's expiration date + +Each public link entry SHALL display an expiration date only when the response provides `expirationDateTime` for that link. When `expirationDateTime` is absent, the entry SHALL NOT display an expiration date row. + +#### Scenario: Link with expiration +- **WHEN** a public link has an `expirationDateTime` value +- **THEN** the entry displays that expiration date + +#### Scenario: Link without expiration +- **WHEN** a public link has no `expirationDateTime` value +- **THEN** the entry displays no expiration date row + +### Requirement: Share a public link's URL + +Each public link entry SHALL present a chain icon on the right side of the entry. When the user taps the chain icon, the system SHALL share that link's `link.webUrl` through the Android share sheet. + +#### Scenario: Share link URL +- **WHEN** a user taps the chain icon on a public link entry +- **THEN** the system opens the Android share sheet offering that link's `link.webUrl` + +### Requirement: Item-type-aware public-links warning text + +The warning text under the `PUBLIC LINKS` header SHALL reference the type of the shared item. When the shared item is a file, the text SHALL read "Anyone with the link has access to the file". When the shared item is a folder, the text SHALL read "Anyone with the link has access to the folder". + +#### Scenario: Warning for a file +- **WHEN** the shared item is a file +- **THEN** the warning text reads "Anyone with the link has access to the file" + +#### Scenario: Warning for a folder +- **WHEN** the shared item is a folder +- **THEN** the warning text reads "Anyone with the link has access to the folder" \ No newline at end of file