From a36d9db4141a5e7afcb4ed5499819fbd97944ce2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 12:32:42 +0200 Subject: [PATCH 1/7] add agent skill for navigator Signed-off-by: alperozturk96 --- .../SKILL.md | 179 ++++++++++++++++++ .../assets/worked-example.md | 74 ++++++++ .../ACTIVITY-TO-FRAGMENT-MAPPING.md | 45 +++++ .../references/ARCHITECTURE.md | 82 ++++++++ .../references/HOST-CAPABILITIES.md | 37 ++++ .../references/PITFALLS.md | 64 +++++++ AGENTS.md | 1 + 7 files changed, 482 insertions(+) create mode 100644 .claude/skills/android-activity-to-navigator-fragment/SKILL.md create mode 100644 .claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md create mode 100644 .claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md create mode 100644 .claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md create mode 100644 .claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md create mode 100644 .claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md diff --git a/.claude/skills/android-activity-to-navigator-fragment/SKILL.md b/.claude/skills/android-activity-to-navigator-fragment/SKILL.md new file mode 100644 index 000000000000..7662010b7868 --- /dev/null +++ b/.claude/skills/android-activity-to-navigator-fragment/SKILL.md @@ -0,0 +1,179 @@ +--- +name: android-activity-to-navigator-fragment +description: > + Use when converting a standalone Activity (usually a DrawerActivity or FileActivity + subclass such as a drawer destination) into a Fragment hosted by NavigatorActivity, when + the user mentions "navigator", "NavigatorActivity", "NavigatorScreen", "convert activity + to fragment", "move screen into the new navigation", or names a drawer screen that still + has its own Activity. Covers registering the NavigatorScreen, building the fragment and + its layout, decoupling collaborators from the old Activity type, rewiring every entry + point (drawer, PendingIntents, broadcast receivers, deep links), DI and manifest cleanup, + and migrating the instrumented screenshot test. +license: AGPL-3.0-or-later +SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later +metadata: + author: Nextcloud Android + version: "1.0.0" +--- + +# Activity → NavigatorActivity Fragment Conversion + +Historically every drawer destination was its own `DrawerActivity` subclass, each with its +own toolbar, `DrawerLayout` and back handling. `NavigatorActivity` replaces them with **one +host Activity** that owns the toolbar and drawer, and swaps **fragments** for the +destinations. Converted so far: Community (#16652), Activities (#16753), Notifications +(#16800), Trashbin (#16879), Upload list. + +Read `references/ARCHITECTURE.md` once before the first conversion. It explains how +`Navigator`, `NavigatorScreen`, `NavigatorActivity` and `DrawerActivity.pushFragment` work +together. + +## The Prime Directive: Behaviour Must Not Change + +The screen must look and behave exactly as before from every entry point: drawer item, +notification tap, notification action, deep link, rotation, back press, drawer toggle and +menu actions. This is a structural refactor. If you find a bug, report it instead of +fixing it in the same change. + +## Workflow + +### Step 0: Inventory (do not edit yet) + +1. Read the whole Activity, its layout and its menu. +2. `git grep -n ""` across `app/src`, including `AndroidManifest.xml`, + `ComponentsModule.java`, KDoc links, `androidTest` and screenshot file names. Every hit is + either an **entry point** you must rewire or a **reference** you must update. +3. List what the Activity takes from its **base class**. This is the step most likely to go + wrong. `NavigatorActivity` is a `DrawerActivity`, **not** a `FileActivity`. Anything from + `FileActivity` (`fileOperationsHelper`, `OperationsService` binding, + `onRemoteOperationFinish`, `requestCredentialsUpdate`, `showLoadingDialog`, + `connectivityService` field, `backgroundJobManager` field, `EXTRA_FILE`/`EXTRA_USER` + handling in `onCreate`) does not exist in the host. See `references/HOST-CAPABILITIES.md`. +4. List the intent extras the Activity, or its base class, reads. Each one must keep working + when it arrives on the `NavigatorActivity` intent. +5. Note any `onActivityResult`, `BroadcastReceiver`, `OnBackPressedCallback`, + `onOptionsItemSelected`, `ActionMode` or drawer listeners. Each one needs a fragment + equivalent (see `references/ACTIVITY-TO-FRAGMENT-MAPPING.md`). + +If a collaborator needs a capability the host does not have, **stop and ask the +developer** whether to (a) move the flow into the fragment and extract the shared pieces +into a helper used by both places, or (b) change the host. Option (a) is the default. It +was chosen for the upload list's credential check (`CredentialsUpdateHelper`). + +### Step 1: Register the screen in `NavigatorScreen` + +Add the screen in all **five** places in `ui/navigation/NavigatorScreen.kt`. The `when` +expressions are exhaustive, so the compiler catches a missing branch, except in `fromTag`: + +1. `@Parcelize object X : NavigatorScreen(X_TAG[, hasDrawer = false])` +2. `private const val X_TAG = "X"` in the companion +3. `fromTag`: `X_TAG -> X` (**not compiler-checked**; missing it breaks restoring after rotation) +4. `menuItemId()`: the drawer item id, or `-1` for screens outside the drawer +5. `actionBarStyle()`: `ActionBarStyle.Plain to R.string.` +6. `toFragment()`: `X -> XFragment()` + +Use `hasDrawer = false` for screens with a back arrow instead of a hamburger (Notifications). + +### Step 2: Fragment layout + +Copy the Activity layout to `fragment_.xml` and strip the shell. The host already +provides it: + +- remove the root `DrawerLayout`, the `` and the + ``; +- remove `layout_below="@id/appbar"`. The host's `FragmentContainerView` already sits below + the app bar; +- keep every view id the code or tests use, so the generated binding properties match; +- keep the original SPDX copyright lines and add a line for the current year (derived file). + +Delete the old layout once nothing references it. + +### Step 3: Write the fragment + +Follow `references/ACTIVITY-TO-FRAGMENT-MAPPING.md`. The essentials: + +- `class XFragment : Fragment()`, with `@Inject lateinit var` dependencies. Injection is done + by `NavigatorActivity.onAttachFragment`, so `Injectable` is optional. +- Nullable binding: `private var binding: FragmentXBinding? = null`. Set it to `null` in + `onDestroyView`. Use `val binding = binding ?: return` in every function that touches views + (#16783 fixed a crash after a configuration change caused by `_binding!!`). +- Host access: `getTypedActivity(NavigatorActivity::class.java)` (or `DrawerActivity` / + `BaseActivity` when that is all you need) for `user`, `storageManager`, `clientRepository`, + `setUser`, `setDrawerIndicatorEnabled`, `startActionMode`. +- Menu: `requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)`. + Do **not** handle `android.R.id.home`. The host does that. +- Back press: implement `NavigatorOnBackPressListener` only if the screen has internal + navigation (Trashbin folders). +- Remove `getMenuItemId()`, `setupDrawer()`, `setupToolbar()`, `highlightNavigationViewItem()` + and title setup. `NavigatorScreen` + `NavigatorActivity.setupActionBar` handle all of these. +- Keep files under 300 lines. Extract cohesive flows (conflict handling, credential checks, + the menu provider) into their own classes in the fragment's package. + +### Step 4: Decouple collaborators from the old Activity + +Adapters, helpers and listeners typed as `XActivity` or `FileActivity` must take the +narrowest type that has what they use. Usually that is `DrawerActivity`, `BaseActivity`, +`NavigatorActivity` or a plain `Context`. Calls to capabilities the host lacks become +callbacks on the click or listener interface, implemented by the fragment +(e.g. `UploadListItemOnClick.onCredentialErrorClick(user)`). + +### Step 5: Rewire every entry point + +**Always** build the intent with `NavigatorActivity.intent(context, NavigatorScreen.X)`. +`NavigatorActivity.onCreate` calls `requireNotNull` on the screen extra, so a bare +`Intent(context, NavigatorActivity::class.java)` crashes (#17193). + +| Old call site | New call site | +|---|---| +| `DrawerActivity.onNavigationItemClicked`: `startActivity(X.class, FLAG_ACTIVITY_CLEAR_TOP)` | `pushFragment(NavigatorScreen.X.INSTANCE)` | +| Buttons in other DrawerActivities: `startActivity(X::class.java)` | `pushFragment(NavigatorScreen.X)` | +| `handleDeepLink`: `startActivity(X.class)` | `pushFragment(NavigatorScreen.X.INSTANCE)` | +| `PendingIntent` / `BroadcastReceiver` / Worker: `Intent(context, X::class.java)` | `NavigatorActivity.intent(context, NavigatorScreen.X).apply { flags = … }` | +| `X.createIntent(...)` factory | inline `NavigatorActivity.intent(...)` + only the extras the fragment reads | + +Keep the original flags (`FLAG_ACTIVITY_NEW_TASK`, `FLAG_ACTIVITY_CLEAR_TOP`, …). Extras the +old base class consumed (e.g. `FileActivity.EXTRA_USER` → `setUser`) must now be read by the +fragment from `activity?.intent`, once, when `savedInstanceState == null`. + +### Step 6: DI, manifest, cleanup + +- `ComponentsModule.java`: remove `abstract XActivity xActivity();` and add + `@ContributesAndroidInjector abstract XFragment xFragment();`. Without this, injection + fails at runtime. +- `AndroidManifest.xml`: remove the `` entry. +- Delete the Activity, update KDoc/Javadoc `[link]`s that pointed at it, and remove now-unused + imports in files you edited. + +### Step 7: Migrate the instrumented test + +Rename `XActivityIT` to `XFragmentIT` and launch through the host: + +```kotlin +val intent = NavigatorActivity.intent(targetContext, NavigatorScreen.X) +ActivityScenario.launch(intent).use { scenario -> … } +``` + +To call into the fragment, find it with `sut.supportFragmentManager.fragments.filterIsInstance()` +(see `TrashbinFragmentIT.launchFragment`). **Rename the reference screenshots with `git mv`** +(`app/screenshots/generic/debug/.XActivityIT_*.png` → `XFragmentIT_*.png`) so the +screenshot test compares against the existing images instead of failing. + +### Step 8: Verify + +1. `./gradlew :app:compileGplayDebugKotlin :app:compileGplayDebugJavaWithJavac :app:compileGplayDebugAndroidTestKotlin` +2. `./gradlew spotlessKotlinCheck detekt lintGplayDebug spotbugsGplayDebug`, then fix findings + in every file you touched. +3. `git grep -n "\|"` returns nothing. +4. Manual checklist (hand it to the developer if you cannot run the app): open from the + drawer, from every notification or PendingIntent, from the deep link; rotate on the + screen; switch to another drawer destination and press back; toggle the drawer with the + hamburger; use every menu action; check light and dark themes. + +## References + +- `references/ARCHITECTURE.md`: how the navigator works. +- `references/ACTIVITY-TO-FRAGMENT-MAPPING.md`: Activity API → fragment equivalent. +- `references/HOST-CAPABILITIES.md`: what `NavigatorActivity` provides and what it does not. +- `references/PITFALLS.md`: bugs that the earlier conversions ran into. +- `assets/worked-example.md`: the upload list conversion, step by step. diff --git a/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md b/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md new file mode 100644 index 000000000000..df91ef882dfa --- /dev/null +++ b/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md @@ -0,0 +1,74 @@ +# Worked Example: `UploadListActivity` → `UploadListFragment` + +This conversion was the hardest one so far, because the Activity extended `FileActivity`. + +## Inventory findings + +- Base class `FileActivity`. The adapter called `activity.fileOperationsHelper.checkCurrentCredentials(user)` + for uploads that failed with `CREDENTIAL_ERROR`. The result came back through + `onRemoteOperationFinish` → `requestCredentialsUpdate` → `CheckRemoteWipeTask` → + `performCredentialsUpdate` → `startActivityForResult(AuthenticatorActivity)` → + `onActivityResult` → `FilesSyncHelper.restartUploadsIfNeeded`. +- Entry points: drawer `nav_uploads`, `FileUploaderIntents.openUploadListIntent` (upload + notifications, with `EXTRA_USER`), `AppWideNotificationManager.getUploadListPendingIntent`, + `SyncConflictNotificationBroadcastReceiver`. +- `EXTRA_USER` was consumed by `FileActivity.onCreate` → `setUser`. `EXTRA_FILE` was + ignored (the Activity set `file = null`). +- Menu: global pause/resume toggle. A `LocalBroadcastManager` receiver for upload events. + `UploadWarningCard` registered in `onCreate` and unregistered in `onDestroy`. + +## Decisions + +- Host stays a `DrawerActivity`. The credential flow moves into the + fragment. +- To avoid copying `FileActivity` logic, `ui/helpers/CredentialsUpdateHelper.kt` was + extracted. `FileActivity.performCredentialsUpdate`, `CheckRemoteWipeTask` and the fragment + all use it. + +## Resulting files + +| File | Role | +|---|---| +| `ui/fragment/uploadList/UploadListFragment.kt` | lifecycle, list setup, receiver, warning card, `EXTRA_USER` | +| `ui/fragment/uploadList/UploadListMenuProvider.kt` | global pause toggle | +| `ui/fragment/uploadList/UploadListConflictHandler.kt` | sync-conflict check and retry | +| `ui/fragment/uploadList/UploadListCredentialsHandler.kt` | check credentials → remote wipe → re-login | +| `ui/helpers/CredentialsUpdateHelper.kt` | credential steps shared with `FileActivity` | +| `res/layout/fragment_upload_list.xml` | old layout without `DrawerLayout`/toolbar/drawer | + +## Collaborator changes + +- `UploadListAdapter(activity: FileActivity)` → `DrawerActivity`. + `fileOperationsHelper.checkCurrentCredentials(user)` → `itemOnClick.onCredentialErrorClick(user)`. +- `UploadListAdapterHelper(activity: FileActivity)` → `DrawerActivity`; + `activity.accountManager` → `activity.userAccountManager`. +- `UploadListItemOnClick` gained `onCredentialErrorClick(user: User)`. + +## Entry point changes + +```kotlin +// FileUploaderIntents +val intent = NavigatorActivity.intent(context, NavigatorScreen.UploadList).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(FileActivity.EXTRA_USER, operation?.user) +} + +// AppWideNotificationManager, SyncConflictNotificationBroadcastReceiver +val intent = NavigatorActivity.intent(context, NavigatorScreen.UploadList).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP +} +``` + +```java +// DrawerActivity.onNavigationItemClicked +} else if (itemId == R.id.nav_uploads) { + resetOnlyPersonalAndOnDevice(); + pushFragment(NavigatorScreen.UploadList.INSTANCE); +``` + +## Cleanup + +- `ComponentsModule`: `uploadListActivity()` removed, `uploadListFragment()` added. +- Manifest entry removed. `UploadListActivity.kt` and `upload_list_layout.xml` deleted. +- `FileUploadEventBroadcaster` KDoc links updated. +- `UploadListActivityActivityIT` → `UploadListFragmentIT`, with screenshots `git mv`'d. diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md b/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md new file mode 100644 index 000000000000..af5128469ed5 --- /dev/null +++ b/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md @@ -0,0 +1,45 @@ +# Activity API → Navigator Fragment Equivalent + +| In the Activity | In the fragment | +|---|---| +| `class X : DrawerActivity()` / `FileActivity()` | `class XFragment : Fragment()` | +| `onCreate`: `setContentView(binding.root)` | `onCreateView`: `binding = FragmentXBinding.inflate(inflater, container, false)` | +| View setup in `onCreate` | `onViewCreated` | +| Non-view setup in `onCreate` (intent extras, config) | `onCreate` (guard extras with `savedInstanceState == null`) | +| `onDestroy` cleanup tied to views/receivers registered in `onCreate` | `onDestroyView` | +| `onStart`/`onStop`/`onResume` | same callbacks on the fragment | +| `setupToolbar()`, `updateActionBarTitleAndHomeButtonByString`, `setupDrawer(menuItemId)` | delete; `NavigatorScreen.actionBarStyle()` + host | +| `getMenuItemId()`, `highlightNavigationViewItem()` | delete; `NavigatorScreen.menuItemId()` + host | +| `onCreateOptionsMenu` / `onOptionsItemSelected` | `MenuProvider` added with `viewLifecycleOwner, Lifecycle.State.RESUMED`; return `false` for ids you do not own | +| `android.R.id.home` → toggle drawer | delete; the host handles it | +| `OnBackPressedCallback` for internal navigation | `NavigatorOnBackPressListener` | +| `mDrawerToggle.isDrawerIndicatorEnabled = x` | `navigatorActivity?.setDrawerIndicatorEnabled(x)` | +| `addDrawerListener(l)` | `activity?.findViewById(R.id.drawer_layout)?.addDrawerListener(l)` | +| `startActionMode(cb)` | `navigatorActivity?.startActionMode(cb)` | +| `findViewById(R.id.sort_button)` (toolbar views) | `activity?.findViewById(...)`; the toolbar belongs to the host | +| `supportFragmentManager` for dialogs | `childFragmentManager`, or `requireActivity().supportFragmentManager` when the dialog calls back into the activity | +| `this` as `Context` | `requireContext()` (inside callbacks: capture before launching) | +| `this` as `Activity` (snackbars, PopupMenu, adapters) | `requireActivity()` / typed host | +| `lifecycleScope` | `lifecycleScope` (fragment lifetime; survives while on back stack) or `viewLifecycleOwner.lifecycleScope` (view work only) | +| `user`, `storageManager`, `clientRepository`, `capabilities` | `navigatorActivity?.user`, `.storageManager`, `.clientRepository`, `.capabilities` | +| `setUser(user)` | `navigatorActivity?.setUser(user)` (`BaseActivity.setUser` is public) | +| `startActivityForResult` + `onActivityResult` | `registerForActivityResult(StartActivityForResult())` as a fragment property | +| `finish()` | `activity?.finish()` | +| `DisplayUtils.showSnackMessage(this, …)` | `DisplayUtils.showSnackMessage(requireView(), …)` / `SnackbarUtil.show(this, …)` | +| inner `BroadcastReceiver` class | `private val receiver = object : BroadcastReceiver() { … }`, avoids an extra type in the file | +| `@Inject var x: T? = null` with `@JvmField` | `@Inject lateinit var x: T` | +| Activity fields inherited from the base (`viewThemeUtils`, `preferences`, `connectivityService`, `accountManager`, `backgroundJobManager`) | `@Inject lateinit var` them in the fragment | + +## Dialog listener lookups + +Dialogs that cast `activity as Listener` break when the listener is now a fragment. +Trashbin fixed `SortingOrderDialogFragment` with this fallback chain: + +```kotlin +val listener = parentFragment as? OnSortingOrderListener + ?: activity as? OnSortingOrderListener + ?: requireActivity().supportFragmentManager.fragments + .firstOrNull { it is OnSortingOrderListener } as? OnSortingOrderListener +``` + +Check every dialog the screen opens for the same pattern. diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md b/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md new file mode 100644 index 000000000000..6a3def4ba1df --- /dev/null +++ b/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md @@ -0,0 +1,82 @@ +# Navigator Architecture + +All classes live in `app/src/main/java/com/owncloud/android/ui/navigation/`. + +``` +NavigatorActivity (DrawerActivity) activity_navigator.xml + ├─ toolbar_standard (appbar) DrawerLayout + ├─ FragmentContainerView ◄── Navigator ──► ├─ CoordinatorLayout + └─ drawer │ ├─ appbar + │ └─ fragment_container_view + └─ drawer +``` + +## `NavigatorScreen`: the destination registry + +A `sealed class NavigatorScreen(val tag: String, val hasDrawer: Boolean = true) : Parcelable`. +Each destination is a `@Parcelize object`, so it can travel in an `Intent` extra and is +compared by identity in `when`. + +| Member | Purpose | +|---|---| +| `tag` | Used as the fragment back-stack entry name. Must be unique | +| `hasDrawer` | `true`: hamburger + unlocked drawer. `false`: back arrow + `LOCK_MODE_LOCKED_CLOSED` | +| `fromTag(tag)` | Turns a back-stack entry name back into a screen. Used after configuration changes | +| `menuItemId()` | Drawer item to highlight (`-1` for none) | +| `actionBarStyle()` | `ActionBarStyle.Plain` → `setupToolbar()`, `Search` → `setupHomeSearchToolbarWithSortAndListButtons()`, plus title res | +| `toFragment()` | Creates a fresh fragment instance | + +## `Navigator`: the fragment stack + +- `push(screen)`: ignores the call if a fragment with that tag already exists. Otherwise it + does `replace(container, screen.toFragment())` + `addToBackStack(screen.tag)`. +- `pop()`: `popBackStack()` and returns the new top screen. +- `getTopScreen()`: reads the name of the last back-stack entry and maps it with `fromTag`. + This keeps working after process death or rotation, because the `FragmentManager` + restores the back stack. The in-memory `stack` does not survive that. + +## `NavigatorActivity`: the host + +- `onCreate`: inflates `ActivityNavigatorBinding`, then `requireNotNull`s the + `EXTRA_SCREEN` extra. It either `push`es the screen (first launch) or restores the action + bar from `getTopScreen()` (re-creation). +- `onAttachFragment`: `AndroidSupportInjection.inject(fragment)` for **every** attached + fragment. This is why converted fragments need a `@ContributesAndroidInjector` entry in + `ComponentsModule`, even when they do not implement `Injectable`. +- `setupActionBar(screen)`: toolbar style, title, drawer or back arrow, drawer lock mode. + Called on every `push`/`pop`. +- Back press (`OnBackPressedCallback`): close the drawer → let the top + `NavigatorOnBackPressListener` intercept → `finish()` if only one entry is left → `pop()`. +- Home button (`onOptionsItemSelected(android.R.id.home)`): listener intercept → back if + `!hasDrawer` → toggle the drawer otherwise. +- `getMenuItemId()` / `onResume`: highlight the drawer item of the top screen. +- `NavigatorActivity.intent(context, screen)`: the **only** correct way to build a launch + intent. + +## `DrawerActivity.pushFragment(screen)` + +```java +public void pushFragment(NavigatorScreen screen) { + if (this instanceof NavigatorActivity navigatorActivity) { + navigatorActivity.push(screen); // already in the navigator: swap fragment + } else { + startActivity(NavigatorActivity.Companion.intent(this, screen)); // e.g. from FileDisplayActivity + } +} +``` + +Drawer item clicks call this, so moving between converted screens swaps fragments +inside a single `NavigatorActivity` instead of starting a new Activity each time. + +## `NavigatorOnBackPressListener` + +```kotlin +interface NavigatorOnBackPressListener { + fun canInterceptBackPress(): Boolean + fun interceptBackPress() +} +``` + +Implement it in a fragment that has its own navigation inside the screen. For example, +`TrashbinFragment` goes up one folder while `trashbinPresenter.isRoot == false`. The host +checks it for both the system back gesture and the toolbar home button. diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md b/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md new file mode 100644 index 000000000000..f02219cfeadc --- /dev/null +++ b/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md @@ -0,0 +1,37 @@ +# What `NavigatorActivity` Provides + +`NavigatorActivity` extends `DrawerActivity → ToolbarActivity → BaseActivity`. It does +**not** extend `FileActivity`. Decided to keep it that way: making every +navigator screen a `FileActivity` would bind `OperationsService` for all of them and show +`FileActivity`'s offline info box on screens that never had one. + +## Available from the host + +- `BaseActivity`: `user`, `setUser(user)`, `capabilities`, `storageManager`, + `clientRepository`, `userAccountManager`, `account`. +- `ToolbarActivity`: toolbar setup, `showInfoBox`/`hideInfoBox` (only called by + `FileActivity`), sort and grid toolbar buttons (`R.id.sort_button`, + `R.id.switch_grid_view_button`). +- `DrawerActivity`: `appPreferences`, `openDrawer`/`closeDrawer`/`isDrawerOpen`, + `setDrawerIndicatorEnabled`, `pushFragment`, `fetchExternalLinks`. + +## Not available: `FileActivity`-only features + +| `FileActivity` feature | How to replace it in a fragment | +|---|---| +| `fileOperationsHelper.*` (queues operations on `OperationsService`) | Run the `RemoteOperation`/`SyncOperation` directly in `lifecycleScope` on `Dispatchers.IO` with a client for the right user | +| `onRemoteOperationFinish(op, result)` override | Handle the result inline after the coroutine returns | +| `requestCredentialsUpdate(account)` → `CheckRemoteWipeTask` → `performCredentialsUpdate` | `CredentialsUpdateHelper` (`isRemoteWipeRequested`, `invalidateCredentials`, `createUpdateCredentialsIntent`) + `registerForActivityResult` | +| `onActivityResult(REQUEST_CODE__UPDATE_CREDENTIALS, RESULT_OK)` | the `ActivityResultLauncher` callback | +| `showLoadingDialog` / `dismissLoadingDialog` | `LoadingDialog.newInstance(msg).show(childFragmentManager, TAG)` and `dismissAllowingStateLoss()` | +| `EXTRA_USER` → `setUser` in `onCreate` | fragment reads `activity?.intent` and calls `navigatorActivity?.setUser` | +| `EXTRA_FILE` → `file` | read it in the fragment if it is used, otherwise drop it from the callers | +| `connectivityService`, `backgroundJobManager`, `accountManager` fields | `@Inject lateinit var` in the fragment | + +## De-duplication rule + +When a fragment needs logic that a base Activity implements privately, **do not copy it**. +Extract the reusable steps into a Kotlin helper (e.g. `ui/helpers/CredentialsUpdateHelper.kt`). +Have the original Activity code call the helper too, so there is one implementation. +Only the orchestration specific to the fragment (coroutines, result launcher, dialog) should +live in the fragment's package. diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md b/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md new file mode 100644 index 000000000000..655eaff60134 --- /dev/null +++ b/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md @@ -0,0 +1,64 @@ +# Pitfalls Found in Earlier Conversions + +## Bare intents crash the host (#17193) + +`NotificationWork` was changed to `Intent(context, NavigatorActivity::class.java)` without +a screen. `NavigatorActivity.onCreate` calls `requireNotNull` on `EXTRA_SCREEN`, so tapping +the notification crashed. Always use `NavigatorActivity.intent(context, NavigatorScreen.X)`. +Check **every** `PendingIntent`, `BroadcastReceiver`, Worker and notification manager that +referenced the old Activity. + +## `binding!!` after a configuration change (#16783) + +The `_binding!!` / `val binding get() = _binding!!` pattern crashed when a callback +(presenter, coroutine, receiver) fired after `onDestroyView`. Use a nullable +`private var binding` and `val binding = binding ?: return` in each function. + +## Screen not restored after rotation (#16783) + +`NavigatorActivity` only re-applies the action bar and drawer highlight if +`NavigatorScreen.fromTag` knows the tag. `fromTag` is not an exhaustive `when` over the +sealed class, so the compiler does not catch a missing entry. + +## Dependency injection + +`NavigatorActivity.onAttachFragment` injects every fragment. That only works if +`ComponentsModule` has a `@ContributesAndroidInjector abstract XFragment xFragment();`. +Without it you get an `IllegalArgumentException: No injector factory bound` at runtime, not at +compile time. `addFragmentOnAttachListener` / lifecycle callbacks did not behave the same, +which is why the deprecated `onAttachFragment` is used. + +## Toolbar views belong to the host + +Sort and grid buttons, the info box and the search bar live in the host's `toolbar_standard`. +Reach them with `activity?.findViewById`. Set their visibility explicitly, because the +previous screen may have changed it. + +## Dialog callbacks + +Dialogs that do `activity as Listener` silently stop working when the listener is now a +fragment (see the `SortingOrderDialogFragment` fix in `ACTIVITY-TO-FRAGMENT-MAPPING.md`). + +## Lifecycle scope choice + +Long-running work started by the user (retrying an upload, executing an action) should use +the fragment's `lifecycleScope`. That keeps it running while the fragment sits on the back +stack with its view destroyed. Only pure view work should use +`viewLifecycleOwner.lifecycleScope`. Always null-check the binding when the work returns. + +## Receivers and registrations + +Anything registered in the Activity's `onCreate` and released in `onDestroy` (e.g. +`UploadWarningCard.register/unregister`) must move to `onViewCreated`/`onDestroyView`. +Otherwise it leaks across back-stack transitions or registers twice. + +## Screenshot tests + +Renaming `XActivityIT` → `XFragmentIT` changes the screenshot names. `git mv` the PNGs in +`app/screenshots/generic/debug/` to match, or CI reports missing references. + +## File size and type rules + +`AGENTS.md` caps files at 300 lines and wants one type per file. Activities converted to +fragments often exceed 300 lines. Split cohesive flows (menu provider, conflict handler, +credentials handler) into separate classes in the fragment's package. diff --git a/AGENTS.md b/AGENTS.md index 7cc05d89e677..79f9715c3bdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ Java, Kotlin, XML, Jetpack Compose are the key technologies used for building th Project-specific skills live in `./.claude/skills//`. Load a skill when the task matches its trigger. - **`android-java-to-kotlin`** (`./.claude/skills/android-java-to-kotlin/SKILL.md`) — Completes a Java-to-Kotlin conversion in this Android app. Use it when finishing a conversion, when the user mentions "java to kotlin", "j2k", "convert java", or "make it idiomatic", or when a freshly IDE-converted `.kt` file needs cleanup. The workflow is two-person: the developer first runs the Android Studio converter (`Code > Convert Java File to Kotlin File`), then the agent drives the idiomatic second pass — fail-fast control flow, function decomposition, `lifecycleScope`/coroutines instead of Java threads, modern Android APIs, and project conventions (SPDX headers, no magic numbers, `@JvmStatic`). The conversion must preserve behaviour, and the agent must write a behaviour-locking test before declaring it done. +- **`android-activity-to-navigator-fragment`** (`./.claude/skills/android-activity-to-navigator-fragment/SKILL.md`) — Converts a standalone Activity (typically a drawer destination) into a Fragment hosted by `NavigatorActivity`. Use it when the user mentions "navigator", `NavigatorActivity`, `NavigatorScreen`, or "convert activity to fragment". It covers registering the `NavigatorScreen`, the fragment and its layout, decoupling collaborators from `FileActivity`, rewiring every entry point with `NavigatorActivity.intent(...)`, DI and manifest cleanup, and migrating the screenshot test. ## General Guidance From 852fc97dcd95d3813d8649e0f130e8e4c64ff7dd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 12:33:58 +0200 Subject: [PATCH 2/7] convert activity to fragment Signed-off-by: alperozturk96 --- ...lient.UploadListFragmentIT_openDrawer.png} | Bin ...dListFragmentIT_openDrawer_dark_black.png} | Bin ...adListFragmentIT_openDrawer_dark_blue.png} | Bin ...dListFragmentIT_openDrawer_dark_white.png} | Bin ...ListFragmentIT_openDrawer_light_black.png} | Bin ...ListFragmentIT_openDrawer_light_white.png} | Bin ...yActivityIT.kt => UploadListFragmentIT.kt} | 12 +- app/src/main/AndroidManifest.xml | 3 - .../nextcloud/client/di/ComponentsModule.java | 8 +- .../android/ui/activity/UploadListActivity.kt | 420 ------------------ .../uploadList/UploadListConflictHandler.kt | 120 +++++ .../UploadListCredentialsHandler.kt | 90 ++++ .../fragment/uploadList/UploadListFragment.kt | 297 +++++++++++++ .../uploadList/UploadListMenuProvider.kt | 73 +++ .../main/res/layout/fragment_upload_list.xml | 47 ++ .../main/res/layout/upload_list_layout.xml | 69 --- 16 files changed, 638 insertions(+), 501 deletions(-) rename app/screenshots/generic/debug/{com.nextcloud.client.UploadListActivityActivityIT_openDrawer.png => com.nextcloud.client.UploadListFragmentIT_openDrawer.png} (100%) rename app/screenshots/generic/debug/{com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_black.png => com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_black.png} (100%) rename app/screenshots/generic/debug/{com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_blue.png => com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_blue.png} (100%) rename app/screenshots/generic/debug/{com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_white.png => com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_white.png} (100%) rename app/screenshots/generic/debug/{com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_black.png => com.nextcloud.client.UploadListFragmentIT_openDrawer_light_black.png} (100%) rename app/screenshots/generic/debug/{com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_white.png => com.nextcloud.client.UploadListFragmentIT_openDrawer_light_white.png} (100%) rename app/src/androidTest/java/com/nextcloud/client/{UploadListActivityActivityIT.kt => UploadListFragmentIT.kt} (76%) delete mode 100755 app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListMenuProvider.kt create mode 100644 app/src/main/res/layout/fragment_upload_list.xml delete mode 100755 app/src/main/res/layout/upload_list_layout.xml diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer.png similarity index 100% rename from app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer.png rename to app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer.png diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_black.png similarity index 100% rename from app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_black.png rename to app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_black.png diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_blue.png similarity index 100% rename from app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_blue.png rename to app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_blue.png diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_white.png similarity index 100% rename from app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_white.png rename to app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_dark_white.png diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_light_black.png similarity index 100% rename from app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_black.png rename to app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_light_black.png diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_light_white.png similarity index 100% rename from app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_white.png rename to app/screenshots/generic/debug/com.nextcloud.client.UploadListFragmentIT_openDrawer_light_white.png diff --git a/app/src/androidTest/java/com/nextcloud/client/UploadListActivityActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/UploadListFragmentIT.kt similarity index 76% rename from app/src/androidTest/java/com/nextcloud/client/UploadListActivityActivityIT.kt rename to app/src/androidTest/java/com/nextcloud/client/UploadListFragmentIT.kt index 64fb9cd3632c..898e41356a91 100644 --- a/app/src/androidTest/java/com/nextcloud/client/UploadListActivityActivityIT.kt +++ b/app/src/androidTest/java/com/nextcloud/client/UploadListFragmentIT.kt @@ -8,7 +8,7 @@ */ package com.nextcloud.client -import androidx.test.core.app.launchActivity +import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.DrawerActions @@ -17,17 +17,19 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import com.owncloud.android.AbstractIT import com.owncloud.android.R -import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen import com.owncloud.android.utils.ScreenshotTest import org.junit.Test -class UploadListActivityActivityIT : AbstractIT() { - private val testClassName = "com.nextcloud.client.UploadListActivityActivityIT" +class UploadListFragmentIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.UploadListFragmentIT" @Test @ScreenshotTest fun openDrawer() { - launchActivity().use { scenario -> + val intent = NavigatorActivity.intent(targetContext, NavigatorScreen.UploadList) + ActivityScenario.launch(intent).use { scenario -> onView(isRoot()).check(matches(isDisplayed())) onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9020a664b936..22a2c841ab65 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -591,9 +591,6 @@ android:launchMode="singleInstance" android:process=":crash" android:theme="@style/Theme.ownCloud.Toolbar" /> - diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 6710289ad52e..7aec1b7b57f6 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -72,7 +72,6 @@ import com.owncloud.android.ui.activity.TextEditorWebView; import com.owncloud.android.ui.activity.ToolbarActivity; import com.owncloud.android.ui.activity.UploadFilesActivity; -import com.owncloud.android.ui.activity.UploadListActivity; import com.owncloud.android.ui.activity.UserInfoActivity; import com.owncloud.android.ui.dialog.AccountRemovalDialog; import com.owncloud.android.ui.dialog.AppPassCodeDialog; @@ -122,6 +121,7 @@ import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; import com.owncloud.android.ui.fragment.notifications.NotificationsFragment; +import com.owncloud.android.ui.fragment.uploadList.UploadListFragment; import com.owncloud.android.ui.navigation.NavigatorActivity; import com.owncloud.android.ui.preview.FileDownloadFragment; import com.owncloud.android.ui.preview.PreviewBitmapActivity; @@ -147,6 +147,9 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract TrashbinFragment trashbinFragment(); + @ContributesAndroidInjector + abstract UploadListFragment uploadListFragment(); + @ContributesAndroidInjector abstract ActivitiesFragment activitiesFragment(); @@ -234,9 +237,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract UploadFilesActivity uploadFilesActivity(); - @ContributesAndroidInjector - abstract UploadListActivity uploadListActivity(); - @ContributesAndroidInjector abstract UserInfoActivity userInfoActivity(); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt deleted file mode 100755 index fe119bc9fc57..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt +++ /dev/null @@ -1,420 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.owncloud.android.ui.activity - -import android.accounts.Account -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.GridLayoutManager -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.client.account.User -import com.nextcloud.client.core.Clock -import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.jobs.upload.AlbumFileUploadWorker -import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster -import com.nextcloud.client.jobs.upload.FileUploadHelper -import com.nextcloud.client.jobs.upload.FileUploadWorker -import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager -import com.nextcloud.client.utils.Throttler -import com.nextcloud.ui.component.UploadWarningCard -import com.nextcloud.utils.extensions.webDavParentPath -import com.owncloud.android.R -import com.owncloud.android.databinding.UploadListLayoutBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.SyncedFolderProvider -import com.owncloud.android.datamodel.UploadsStorageManager -import com.owncloud.android.db.OCUpload -import com.owncloud.android.lib.common.operations.RemoteOperation -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation -import com.owncloud.android.operations.CheckCurrentCredentialsOperation -import com.nextcloud.utils.thumbnail.ThumbnailGenerator -import com.owncloud.android.operations.factory.UploadFileOperationFactory -import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter -import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult -import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterAction -import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler -import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper -import com.owncloud.android.ui.adapter.uploadList.helper.UploadListItemOnClick -import com.owncloud.android.ui.decoration.MediaGridItemDecoration -import com.owncloud.android.utils.FilesSyncHelper -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@Suppress("MagicNumber") -class UploadListActivity : - FileActivity(), - UploadListItemOnClick { - @Inject lateinit var uploadsStorageManager: UploadsStorageManager - - @Inject lateinit var powerManagementService: PowerManagementService - - @Inject lateinit var clock: Clock - - @Inject lateinit var syncedFolderProvider: SyncedFolderProvider - - @Inject lateinit var localBroadcastManager: LocalBroadcastManager - - @Inject lateinit var throttler: Throttler - - @Inject lateinit var uploadFileOperationFactory: UploadFileOperationFactory - - @Inject lateinit var thumbnailGenerator: ThumbnailGenerator - - private var uploadWarningCard: UploadWarningCard? = null - - private var swipeListRefreshLayout: SwipeRefreshLayout? = null - private var binding: UploadListLayoutBinding? = null - - private var uploadFinishReceiver: UploadFinishReceiver? = null - private lateinit var uploadListAdapter: UploadListAdapter - private lateinit var adapterActionHandler: UploadListAdapterAction - private lateinit var adapterHelper: UploadListAdapterHelper - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - throttler.intervalMillis = 1000 - binding = UploadListLayoutBinding.inflate(layoutInflater) - val binding = binding!! - setContentView(binding.getRoot()) - uploadWarningCard = UploadWarningCard( - this, - powerManagementService, - syncedFolderProvider, - backgroundJobManager, - lifecycleScope, - viewThemeUtils - ) - swipeListRefreshLayout = binding.swipeContainingList - - // this activity has no file really bound, it's for multiple accounts at the same time; should no inherit - // from FileActivity; moreover, some behaviours inherited from FileActivity should be delegated to Fragments; - // but that's other story - file = null - - setupToolbar() - updateActionBarTitleAndHomeButtonByString(getString(R.string.uploads_view_title)) - setupDrawer(menuItemId) - setupContent() - } - - override fun getMenuItemId() = R.id.nav_uploads - - private fun setupContent() { - setupEmptyList() - adapterActionHandler = UploadListAdapterActionHandler() - adapterHelper = UploadListAdapterHelper(this) - uploadListAdapter = UploadListAdapter( - this, - storageManager, - uploadsStorageManager, - userAccountManager, - connectivityService, - powerManagementService, - viewThemeUtils, - this, - adapterHelper, - thumbnailGenerator - ) - - binding?.autoUploadBatterySaverWarningCard?.let { - uploadWarningCard?.register(this, it) - } - - val lm = GridLayoutManager(this, 1) - uploadListAdapter.setLayoutManager(lm) - - val spacing = getResources().getDimensionPixelSize(R.dimen.media_grid_spacing) - binding?.list?.run { - addItemDecoration(MediaGridItemDecoration(spacing)) - setLayoutManager(lm) - setAdapter(uploadListAdapter) - } - - swipeListRefreshLayout?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - swipeListRefreshLayout?.setOnRefreshListener { this.refresh() } - } - - private fun setupEmptyList() { - binding?.run { - list.setEmptyView(emptyList.getRoot()) - emptyList.run { - root.visibility = View.GONE - - emptyListIcon.run { - setImageResource(R.drawable.uploads) - getDrawable().mutate() - setAlpha(0.5f) - setVisibility(View.VISIBLE) - } - - emptyListViewHeadline.text = getString(R.string.upload_list_empty_headline) - - emptyListViewText.run { - text = getString(R.string.upload_list_empty_text_auto_upload) - visibility = View.VISIBLE - } - } - } - } - - private fun loadItems() { - swipeListRefreshLayout?.isRefreshing = true - uploadListAdapter.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } - } - - private fun refresh() { - FileUploadHelper.instance().retryFailedUploads( - uploadsStorageManager, - connectivityService, - accountManager, - powerManagementService - ) - - loadItems() - } - - override fun onStart() { - Log_OC.v(TAG, "onStart() start") - super.onStart() - - highlightNavigationViewItem(menuItemId) - - uploadFinishReceiver = UploadFinishReceiver() - val intentFilter = IntentFilter().apply { - addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_ENQUEUED) - addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_STARTED) - addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_COMPLETED) - } - uploadFinishReceiver?.let { localBroadcastManager.registerReceiver(it, intentFilter) } - - Log_OC.v(TAG, "onStart() end") - } - - override fun onStop() { - Log_OC.v(TAG, "onStop() start") - if (uploadFinishReceiver != null) { - uploadFinishReceiver?.let { localBroadcastManager.unregisterReceiver(it) } - uploadFinishReceiver = null - } - super.onStop() - Log_OC.v(TAG, "onStop() end") - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_upload_list, menu) - menu.findItem(R.id.action_toggle_global_pause)?.let { updateGlobalPauseIcon(it) } - return true - } - - private fun updateGlobalPauseIcon(item: MenuItem) { - val paused = preferences.isGlobalUploadPaused() - item.setIcon(if (paused) R.drawable.ic_global_resume else R.drawable.ic_global_pause) - item.title = getString( - if (paused) { - R.string.upload_action_global_upload_resume - } else { - R.string.upload_action_global_upload_pause - } - ) - } - - @SuppressLint("NotifyDataSetChanged") - private fun toggleGlobalPause(item: MenuItem) { - val paused = !preferences.isGlobalUploadPaused() - preferences.setGlobalUploadPaused(paused) - updateGlobalPauseIcon(item) - - if (paused) { - FileUploadWorker.pauseActiveUploads() - AlbumFileUploadWorker.pauseActiveUploads() - } - - val uploadHelper = FileUploadHelper.instance() - accountManager.getAllUsers().filterNotNull().forEach { user -> - val ids = uploadsStorageManager.getCurrentUploadIds(user.accountName) - uploadHelper.cancelAndRestartUploadJob(user, ids) - } - uploadListAdapter.notifyDataSetChanged() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - android.R.id.home -> { - if (isDrawerOpen) closeDrawer() else openDrawer() - true - } - - R.id.action_toggle_global_pause -> { - toggleGlobalPause(item) - true - } - - else -> super.onOptionsItemSelected(item) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_CODE__UPDATE_CREDENTIALS && resultCode == RESULT_OK) { - FilesSyncHelper.restartUploadsIfNeeded( - uploadsStorageManager, - userAccountManager, - connectivityService, - powerManagementService - ) - } - } - - override fun onResume() { - super.onResume() - binding?.autoUploadBatterySaverWarningCard?.let { - uploadWarningCard?.bind(it) - } - loadItems() - } - - override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) { - if (operation !is CheckCurrentCredentialsOperation) { - super.onRemoteOperationFinish(operation, result) - return - } - - fileOperationsHelper.opIdWaitingFor = Long.MAX_VALUE - dismissLoadingDialog() - val account = result.data[0] as? Account - if (!result.isSuccess) { - requestCredentialsUpdate(account) - } else { - FilesSyncHelper.restartUploadsIfNeeded( - uploadsStorageManager, - userAccountManager, - connectivityService, - powerManagementService - ) - } - } - - private var conflictSnackbar: Snackbar? = null - - override fun onLastUploadResultConflictClick(upload: OCUpload) { - val rootView = binding?.root ?: return - - conflictSnackbar = Snackbar.make( - rootView, - R.string.upload_sync_conflict_checking, - Snackbar.LENGTH_INDEFINITE - ).apply { show() } - - lifecycleScope.launch { - val client = clientRepository.getOwncloudClient() ?: return@launch - val result = adapterActionHandler.handleConflict(upload, client, uploadsStorageManager) - - withContext(Dispatchers.Main) { - when (result) { - is ConflictHandlingResult.ConflictNotExistsRemoteFileNotFound -> { - showConflictSnackbar(R.string.upload_sync_conflict_not_exists) - retryUpload(upload) - } - - is ConflictHandlingResult.CannotCheckConflict -> { - showConflictSnackbar(R.string.upload_sync_conflict_check_error) - } - - is ConflictHandlingResult.ShowConflictResolveDialog -> { - conflictSnackbar?.dismiss() - adapterHelper.openConflictActivity(result.file, result.upload) - } - - is ConflictHandlingResult.ConflictNotExistsSameFile -> { - showConflictSnackbar(R.string.upload_sync_conflict_same_file) - } - } - } - } - } - - private fun retryUpload(upload: OCUpload) { - lifecycleScope.launch(Dispatchers.IO) { - val client = clientRepository.getOwncloudClient() - - // Check parent folder exists - val file = storageManager.getFileByPath(upload.remotePath) - val parentPath = (file?.parentRemotePath ?: upload.remotePath?.webDavParentPath()) - - parentPath?.let { - val checkOp = ExistenceCheckRemoteOperation(it, false) - val checkResult = checkOp.execute(client) - - if (!checkResult.isSuccess && - checkResult.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND - ) { - withContext(Dispatchers.Main) { - showConflictSnackbar(R.string.uploader_file_not_found_message) - } - return@launch - } - } - - val result = uploadFileOperationFactory - .create(this@UploadListActivity, upload) - .execute(client) - - if (result.isSuccess) { - withContext(Dispatchers.Main) { - UploadErrorNotificationManager.dismissConflictResolveNotification( - this@UploadListActivity, - upload.uploadId - ) - uploadListAdapter.loadUploadItemsFromDb() - } - } - } - } - - private fun showConflictSnackbar(messageId: Int) { - conflictSnackbar?.apply { - setText(messageId) - duration = Snackbar.LENGTH_LONG - show() - } - } - - private inner class UploadFinishReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - throttler.run("update_upload_list") { uploadListAdapter.loadUploadItemsFromDb() } - } - } - - override fun onDestroy() { - super.onDestroy() - uploadWarningCard?.unregister(this) - } - - companion object { - private val TAG: String = UploadListActivity::class.java.getSimpleName() - - fun createIntent(file: OCFile?, user: User?, flag: Int, context: Context): Intent = - Intent(context, UploadListActivity::class.java).apply { - setFlags(flags or flag) - putExtra(EXTRA_FILE, file) - putExtra(EXTRA_USER, user) - } - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt new file mode 100644 index 000000000000..c2e594e842bc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt @@ -0,0 +1,120 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment.uploadList + +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager +import com.nextcloud.utils.extensions.getTypedActivity +import com.nextcloud.utils.extensions.webDavParentPath +import com.owncloud.android.R +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter +import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterAction +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper +import com.owncloud.android.ui.navigation.NavigatorActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class UploadListConflictHandler( + private val fragment: Fragment, + private val uploadsStorageManager: UploadsStorageManager, + private val uploadFileOperationFactory: UploadFileOperationFactory, + private val adapterHelper: UploadListAdapterHelper, + private val uploadListAdapter: UploadListAdapter +) { + private val adapterActionHandler: UploadListAdapterAction = UploadListAdapterActionHandler() + private var conflictSnackbar: Snackbar? = null + + fun handleConflict(upload: OCUpload) { + val rootView = fragment.view ?: return + val activity = fragment.getTypedActivity(NavigatorActivity::class.java) ?: return + + conflictSnackbar = Snackbar.make( + rootView, + R.string.upload_sync_conflict_checking, + Snackbar.LENGTH_INDEFINITE + ).apply { show() } + + fragment.lifecycleScope.launch { + val client = activity.clientRepository.getOwncloudClient() ?: return@launch + val result = adapterActionHandler.handleConflict(upload, client, uploadsStorageManager) + + withContext(Dispatchers.Main) { + when (result) { + is ConflictHandlingResult.ConflictNotExistsRemoteFileNotFound -> { + showConflictSnackbar(R.string.upload_sync_conflict_not_exists) + retryUpload(activity, upload) + } + + is ConflictHandlingResult.CannotCheckConflict -> { + showConflictSnackbar(R.string.upload_sync_conflict_check_error) + } + + is ConflictHandlingResult.ShowConflictResolveDialog -> { + conflictSnackbar?.dismiss() + adapterHelper.openConflictActivity(result.file, result.upload) + } + + is ConflictHandlingResult.ConflictNotExistsSameFile -> { + showConflictSnackbar(R.string.upload_sync_conflict_same_file) + } + } + } + } + } + + private fun retryUpload(activity: NavigatorActivity, upload: OCUpload) { + fragment.lifecycleScope.launch(Dispatchers.IO) { + val client = activity.clientRepository.getOwncloudClient() + + val file = activity.storageManager.getFileByPath(upload.remotePath) + val parentPath = (file?.parentRemotePath ?: upload.remotePath?.webDavParentPath()) + + parentPath?.let { + val checkResult = ExistenceCheckRemoteOperation(it, false).execute(client) + + if (!checkResult.isSuccess && + checkResult.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND + ) { + withContext(Dispatchers.Main) { + showConflictSnackbar(R.string.uploader_file_not_found_message) + } + return@launch + } + } + + val result = uploadFileOperationFactory + .create(activity, upload) + .execute(client) + + if (result.isSuccess) { + withContext(Dispatchers.Main) { + UploadErrorNotificationManager.dismissConflictResolveNotification(activity, upload.uploadId) + uploadListAdapter.loadUploadItemsFromDb() + } + } + } + } + + private fun showConflictSnackbar(@StringRes messageId: Int) { + conflictSnackbar?.apply { + setText(messageId) + duration = Snackbar.LENGTH_LONG + show() + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt new file mode 100644 index 000000000000..69f0e3720621 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment.uploadList + +import android.accounts.Account +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.utils.SnackbarUtil +import com.owncloud.android.R +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.LoadingDialog +import com.owncloud.android.ui.helpers.CredentialsUpdateHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class UploadListCredentialsHandler( + private val fragment: Fragment, + private val backgroundJobManager: BackgroundJobManager, + private val credentialsUpdateLauncher: ActivityResultLauncher, + private val onCredentialsValid: () -> Unit +) { + companion object { + private const val TAG = "UploadListCredentialsHandler" + private const val LOADING_DIALOG_TAG = "UPLOAD_LIST_LOADING_DIALOG" + } + + fun checkCredentials(user: User) { + val credentialsUpdateHelper = CredentialsUpdateHelper(fragment.requireContext()) + showLoadingDialog() + + fragment.lifecycleScope.launch { + val isValid = withContext(Dispatchers.IO) { + credentialsUpdateHelper.areCurrentCredentialsValid(user) + } + dismissLoadingDialog() + + if (isValid) { + onCredentialsValid() + return@launch + } + + requestCredentialsUpdate(credentialsUpdateHelper, user.toPlatformAccount()) + } + } + + private suspend fun requestCredentialsUpdate(credentialsUpdateHelper: CredentialsUpdateHelper, account: Account) { + val isRemoteWipeRequested = withContext(Dispatchers.IO) { + credentialsUpdateHelper.isRemoteWipeRequested(account) + } + + if (isRemoteWipeRequested) { + backgroundJobManager.startAccountRemovalJob(account.name, true) + return + } + + try { + withContext(Dispatchers.IO) { credentialsUpdateHelper.invalidateCredentials(account) } + credentialsUpdateLauncher.launch(credentialsUpdateHelper.createUpdateCredentialsIntent(account)) + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(TAG, "Account not found while updating credentials: $e") + SnackbarUtil.show(fragment, R.string.auth_account_does_not_exist) + } + } + + private fun showLoadingDialog() { + val fragmentManager = fragment.childFragmentManager + if (fragmentManager.isStateSaved) { + return + } + + LoadingDialog.newInstance(fragment.getString(R.string.wait_checking_credentials)) + .show(fragmentManager, LOADING_DIALOG_TAG) + } + + private fun dismissLoadingDialog() { + val dialog = fragment.childFragmentManager.findFragmentByTag(LOADING_DIALOG_TAG) as? DialogFragment + dialog?.dismissAllowingStateLoss() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt new file mode 100644 index 000000000000..b94de86606ce --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt @@ -0,0 +1,297 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment.uploadList + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.GridLayoutManager +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.Throttler +import com.nextcloud.ui.component.UploadWarningCard +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.getTypedActivity +import com.nextcloud.utils.thumbnail.ThumbnailGenerator +import com.owncloud.android.R +import com.owncloud.android.databinding.FragmentUploadListBinding +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListItemOnClick +import com.owncloud.android.ui.decoration.MediaGridItemDecoration +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.utils.FilesSyncHelper +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +@Suppress("TooManyFunctions") +class UploadListFragment : + Fragment(), + UploadListItemOnClick { + + @Inject lateinit var uploadsStorageManager: UploadsStorageManager + + @Inject lateinit var powerManagementService: PowerManagementService + + @Inject lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject lateinit var localBroadcastManager: LocalBroadcastManager + + @Inject lateinit var throttler: Throttler + + @Inject lateinit var uploadFileOperationFactory: UploadFileOperationFactory + + @Inject lateinit var thumbnailGenerator: ThumbnailGenerator + + @Inject lateinit var userAccountManager: UserAccountManager + + @Inject lateinit var connectivityService: ConnectivityService + + @Inject lateinit var backgroundJobManager: BackgroundJobManager + + @Inject lateinit var preferences: AppPreferences + + @Inject lateinit var viewThemeUtils: ViewThemeUtils + + private var binding: FragmentUploadListBinding? = null + private var uploadWarningCard: UploadWarningCard? = null + private lateinit var uploadListAdapter: UploadListAdapter + private lateinit var conflictHandler: UploadListConflictHandler + private lateinit var credentialsHandler: UploadListCredentialsHandler + + private val navigatorActivity: NavigatorActivity? + get() = getTypedActivity(NavigatorActivity::class.java) + + private val credentialsUpdateLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + restartUploadsIfNeeded() + } + } + + private val uploadFinishReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + throttler.run(UPDATE_UPLOAD_LIST_KEY) { uploadListAdapter.loadUploadItemsFromDb() } + } + } + + // region Lifecycle + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + throttler.intervalMillis = THROTTLE_INTERVAL_MILLIS + if (savedInstanceState == null) { + setUserFromIntent() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentUploadListBinding.inflate(inflater, container, false) + val binding = binding!! + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val activity = navigatorActivity ?: return + val binding = binding ?: return + + setupWarningCard(activity, binding) + setupEmptyList(binding) + setupList(activity, binding) + addMenuProvider() + } + + override fun onStart() { + super.onStart() + val intentFilter = IntentFilter().apply { + addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_ENQUEUED) + addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_STARTED) + addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_COMPLETED) + } + localBroadcastManager.registerReceiver(uploadFinishReceiver, intentFilter) + } + + override fun onResume() { + super.onResume() + binding?.autoUploadBatterySaverWarningCard?.let { uploadWarningCard?.bind(it) } + loadItems() + } + + override fun onStop() { + localBroadcastManager.unregisterReceiver(uploadFinishReceiver) + super.onStop() + } + + override fun onDestroyView() { + uploadWarningCard?.unregister(requireContext()) + uploadWarningCard = null + super.onDestroyView() + binding = null + } + // endregion + + // region UploadListItemOnClick + override fun onLastUploadResultConflictClick(upload: OCUpload) { + conflictHandler.handleConflict(upload) + } + + override fun onCredentialErrorClick(user: User) { + credentialsHandler.checkCredentials(user) + } + // endregion + + // region Setup + private fun setUserFromIntent() { + val user = activity?.intent?.getParcelableArgument(FileActivity.EXTRA_USER, User::class.java) ?: return + navigatorActivity?.setUser(user) + } + + private fun setupWarningCard(activity: NavigatorActivity, binding: FragmentUploadListBinding) { + uploadWarningCard = UploadWarningCard( + activity, + powerManagementService, + syncedFolderProvider, + backgroundJobManager, + viewLifecycleOwner.lifecycleScope, + viewThemeUtils + ).apply { + register(activity, binding.autoUploadBatterySaverWarningCard) + } + } + + private fun setupEmptyList(binding: FragmentUploadListBinding) { + binding.list.setEmptyView(binding.emptyList.root) + binding.emptyList.run { + root.visibility = View.GONE + + emptyListIcon.run { + setImageResource(R.drawable.uploads) + drawable.mutate() + alpha = EMPTY_LIST_ICON_ALPHA + visibility = View.VISIBLE + } + + emptyListViewHeadline.text = getString(R.string.upload_list_empty_headline) + + emptyListViewText.run { + text = getString(R.string.upload_list_empty_text_auto_upload) + visibility = View.VISIBLE + } + } + } + + private fun setupList(activity: NavigatorActivity, binding: FragmentUploadListBinding) { + val adapterHelper = UploadListAdapterHelper(activity) + uploadListAdapter = UploadListAdapter( + activity, + activity.storageManager, + uploadsStorageManager, + userAccountManager, + connectivityService, + powerManagementService, + viewThemeUtils, + this, + adapterHelper, + thumbnailGenerator + ) + conflictHandler = UploadListConflictHandler( + this, + uploadsStorageManager, + uploadFileOperationFactory, + adapterHelper, + uploadListAdapter + ) + credentialsHandler = UploadListCredentialsHandler(this, backgroundJobManager, credentialsUpdateLauncher) { + restartUploadsIfNeeded() + } + + val layoutManager = GridLayoutManager(activity, SPAN_COUNT) + uploadListAdapter.setLayoutManager(layoutManager) + + val spacing = resources.getDimensionPixelSize(R.dimen.media_grid_spacing) + binding.list.run { + addItemDecoration(MediaGridItemDecoration(spacing)) + setLayoutManager(layoutManager) + adapter = uploadListAdapter + } + + viewThemeUtils.androidx.themeSwipeRefreshLayout(binding.swipeContainingList) + binding.swipeContainingList.setOnRefreshListener { refresh() } + } + + @SuppressLint("NotifyDataSetChanged") + private fun addMenuProvider() { + val activity = requireActivity() + val menuProvider = UploadListMenuProvider( + activity, + preferences, + userAccountManager, + uploadsStorageManager + ) { uploadListAdapter.notifyDataSetChanged() } + + activity.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + // endregion + + // region Uploads + private fun loadItems() { + val binding = binding ?: return + binding.swipeContainingList.isRefreshing = true + uploadListAdapter.loadUploadItemsFromDb { this.binding?.swipeContainingList?.isRefreshing = false } + } + + private fun refresh() { + FileUploadHelper.instance().retryFailedUploads( + uploadsStorageManager, + connectivityService, + userAccountManager, + powerManagementService + ) + + loadItems() + } + + private fun restartUploadsIfNeeded() { + FilesSyncHelper.restartUploadsIfNeeded( + uploadsStorageManager, + userAccountManager, + connectivityService, + powerManagementService + ) + } + // endregion + + companion object { + private const val THROTTLE_INTERVAL_MILLIS = 1000L + private const val SPAN_COUNT = 1 + private const val EMPTY_LIST_ICON_ALPHA = 0.5f + private const val UPDATE_UPLOAD_LIST_KEY = "update_upload_list" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListMenuProvider.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListMenuProvider.kt new file mode 100644 index 000000000000..da637f6bcf75 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListMenuProvider.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment.uploadList + +import android.content.Context +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.upload.AlbumFileUploadWorker +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import com.owncloud.android.datamodel.UploadsStorageManager + +class UploadListMenuProvider( + private val context: Context, + private val preferences: AppPreferences, + private val userAccountManager: UserAccountManager, + private val uploadsStorageManager: UploadsStorageManager, + private val onGlobalPauseToggled: () -> Unit +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_upload_list, menu) + menu.findItem(R.id.action_toggle_global_pause)?.let { updateGlobalPauseIcon(it) } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId != R.id.action_toggle_global_pause) { + return false + } + + toggleGlobalPause(menuItem) + return true + } + + private fun updateGlobalPauseIcon(item: MenuItem) { + val paused = preferences.isGlobalUploadPaused() + item.setIcon(if (paused) R.drawable.ic_global_resume else R.drawable.ic_global_pause) + item.title = context.getString( + if (paused) { + R.string.upload_action_global_upload_resume + } else { + R.string.upload_action_global_upload_pause + } + ) + } + + private fun toggleGlobalPause(item: MenuItem) { + val paused = !preferences.isGlobalUploadPaused() + preferences.setGlobalUploadPaused(paused) + updateGlobalPauseIcon(item) + + if (paused) { + FileUploadWorker.pauseActiveUploads() + AlbumFileUploadWorker.pauseActiveUploads() + } + + val uploadHelper = FileUploadHelper.instance() + userAccountManager.allUsers.filterNotNull().forEach { user -> + val ids = uploadsStorageManager.getCurrentUploadIds(user.accountName) + uploadHelper.cancelAndRestartUploadJob(user, ids) + } + onGlobalPauseToggled() + } +} diff --git a/app/src/main/res/layout/fragment_upload_list.xml b/app/src/main/res/layout/fragment_upload_list.xml new file mode 100644 index 000000000000..c862556ea8f8 --- /dev/null +++ b/app/src/main/res/layout/fragment_upload_list.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/upload_list_layout.xml b/app/src/main/res/layout/upload_list_layout.xml deleted file mode 100755 index ed23628e89a6..000000000000 --- a/app/src/main/res/layout/upload_list_layout.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From b928ed6389b2a67dfbf605e3b9db018c03e74cfe Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 12:34:20 +0200 Subject: [PATCH 3/7] extract common logic that needed from file activity Signed-off-by: alperozturk96 --- .../android/ui/activity/FileActivity.java | 35 ++-------- .../ui/asynctasks/CheckRemoteWipeTask.java | 9 +-- .../ui/helpers/CredentialsUpdateHelper.kt | 66 +++++++++++++++++++ 3 files changed, 75 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java index 494b1a8ae6d4..25d3f80c2a09 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -15,7 +15,6 @@ package com.owncloud.android.ui.activity; import android.accounts.Account; -import android.accounts.AccountManager; import android.accounts.AuthenticatorException; import android.app.Activity; import android.content.ComponentName; @@ -53,9 +52,6 @@ import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.lib.common.OwnCloudAccount; -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; -import com.owncloud.android.lib.common.OwnCloudCredentials; import com.owncloud.android.lib.common.network.CertificateCombinedException; import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; import com.owncloud.android.lib.common.operations.RemoteOperation; @@ -95,6 +91,7 @@ import com.owncloud.android.ui.fragment.albums.AlbumsFragment; import com.owncloud.android.ui.fragment.filesRepository.FilesRepository; import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository; +import com.owncloud.android.ui.helpers.CredentialsUpdateHelper; import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.ui.preview.PreviewImageActivity; import com.owncloud.android.utils.ClipboardUtil; @@ -474,32 +471,12 @@ protected void requestCredentialsUpdate(Account account) { new CheckRemoteWipeTask(backgroundJobManager, account, new WeakReference<>(this)).execute(); } - public void performCredentialsUpdate(Account account, Context context) { + public void performCredentialsUpdate(Account account) { + final var credentialsUpdateHelper = new CredentialsUpdateHelper(this); try { - /// step 1 - invalidate credentials of current account - OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); - OwnCloudClient client = OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(ocAccount); - - if (client != null) { - OwnCloudCredentials credentials = client.getCredentials(); - if (credentials != null) { - AccountManager accountManager = AccountManager.get(context); - if (credentials.authTokenExpires()) { - accountManager.invalidateAuthToken(account.type, credentials.getAuthToken()); - } else { - accountManager.clearPassword(account); - } - } - } - - /// step 2 - request credentials to user - Intent updateAccountCredentials = new Intent(context, AuthenticatorActivity.class); - updateAccountCredentials.putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, account); - updateAccountCredentials.putExtra( - AuthenticatorActivity.EXTRA_ACTION, - AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN); - updateAccountCredentials.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - startActivityForResult(updateAccountCredentials, REQUEST_CODE__UPDATE_CREDENTIALS); + credentialsUpdateHelper.invalidateCredentials(account); + startActivityForResult(credentialsUpdateHelper.createUpdateCredentialsIntent(account), + REQUEST_CODE__UPDATE_CREDENTIALS); } catch (com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException e) { SnackbarUtil.show(this, R.string.auth_account_does_not_exist); } diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/CheckRemoteWipeTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/CheckRemoteWipeTask.java index a5d1db9f2633..ea133e5cac9a 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/CheckRemoteWipeTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/CheckRemoteWipeTask.java @@ -16,10 +16,9 @@ import android.os.AsyncTask; import com.nextcloud.client.jobs.BackgroundJobManager; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.users.CheckRemoteWipeRemoteOperation; import com.owncloud.android.ui.activity.FileActivity; +import com.owncloud.android.ui.helpers.CredentialsUpdateHelper; import java.lang.ref.WeakReference; @@ -45,13 +44,11 @@ protected Boolean doInBackground(Void... voids) { return Boolean.FALSE; } - RemoteOperationResult checkWipeResult = new CheckRemoteWipeRemoteOperation().execute(account, fileActivity); - - if (checkWipeResult.isSuccess()) { + if (new CredentialsUpdateHelper(fileActivity).isRemoteWipeRequested(account)) { backgroundJobManager.startAccountRemovalJob(account.name, true); } else { Log_OC.e(this, "Check for remote wipe not needed -> update credentials"); - fileActivity.performCredentialsUpdate(account, fileActivity); + fileActivity.performCredentialsUpdate(account); } return Boolean.TRUE; diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt b/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt new file mode 100644 index 000000000000..ab56f6c4afda --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.helpers + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.content.Intent +import androidx.annotation.WorkerThread +import com.nextcloud.client.account.User +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.CheckRemoteWipeRemoteOperation +import com.owncloud.android.operations.CheckCurrentCredentialsOperation + +class CredentialsUpdateHelper(private val context: Context) { + + companion object { + private const val TAG = "CredentialsUpdateHelper" + } + + @WorkerThread + fun areCurrentCredentialsValid(user: User): Boolean = runCatching { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), context) + val storageManager = FileDataStorageManager(user, context.contentResolver) + CheckCurrentCredentialsOperation(user, storageManager).execute(client).isSuccess + }.getOrElse { + Log_OC.e(TAG, "Error while checking credentials of ${user.accountName}", it) + false + } + + @WorkerThread + fun isRemoteWipeRequested(account: Account): Boolean = + CheckRemoteWipeRemoteOperation().execute(account, context).isSuccess + + @Throws(AccountUtils.AccountNotFoundException::class) + fun invalidateCredentials(account: Account) { + val ocAccount = OwnCloudAccount(account, context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(ocAccount) ?: return + val credentials = client.credentials ?: return + val accountManager = AccountManager.get(context) + + if (credentials.authTokenExpires()) { + accountManager.invalidateAuthToken(account.type, credentials.authToken) + } else { + accountManager.clearPassword(account) + } + } + + fun createUpdateCredentialsIntent(account: Account): Intent = + Intent(context, AuthenticatorActivity::class.java).apply { + putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, account) + putExtra(AuthenticatorActivity.EXTRA_ACTION, AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } +} From 8a52ad6502fa4fe81f218545d402fd79efad17ab Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 12:34:25 +0200 Subject: [PATCH 4/7] update callers Signed-off-by: alperozturk96 --- .../jobs/upload/FileUploadEventBroadcaster.kt | 6 +++--- .../client/jobs/upload/FileUploaderIntents.kt | 14 +++++++------- .../notifications/AppWideNotificationManager.kt | 5 +++-- .../SyncConflictNotificationBroadcastReceiver.kt | 5 +++-- .../android/ui/activity/DrawerActivity.java | 2 +- .../ui/adapter/uploadList/UploadListAdapter.kt | 6 +++--- .../uploadList/helper/UploadListAdapterHelper.kt | 6 +++--- .../uploadList/helper/UploadListItemOnClick.kt | 2 ++ .../android/ui/navigation/NavigatorScreen.kt | 9 +++++++++ 9 files changed, 34 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt index 5ddd61f20f09..3eadcd9cdbbc 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt @@ -47,7 +47,7 @@ class FileUploadEventBroadcaster(private val broadcastManager: LocalBroadcastMan * - [UploadFileOperation] added * * ### Observed by - * - [com.owncloud.android.ui.activity.UploadListActivity.UploadFinishReceiver] + * - [com.owncloud.android.ui.fragment.uploadList.UploadListFragment] * */ fun sendUploadEnqueued(context: Context) { @@ -66,7 +66,7 @@ class FileUploadEventBroadcaster(private val broadcastManager: LocalBroadcastMan * - [UploadFileOperation] started * * ### Observed by - * - [com.owncloud.android.ui.activity.UploadListActivity.UploadFinishReceiver] + * - [com.owncloud.android.ui.fragment.uploadList.UploadListFragment] * */ fun sendUploadStarted(upload: UploadFileOperation, context: Context) { @@ -90,7 +90,7 @@ class FileUploadEventBroadcaster(private val broadcastManager: LocalBroadcastMan * * ### Observed by * - [com.owncloud.android.ui.activity.FileDisplayActivity.FileUploadCompletedReceiver] - * - [com.owncloud.android.ui.activity.UploadListActivity.UploadFinishReceiver] + * - [com.owncloud.android.ui.fragment.uploadList.UploadListFragment] * */ fun sendUploadCompleted(upload: UploadFileOperation, uploadResult: RemoteOperationResult<*>, context: Context) { diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt index b8341f26cafe..635b3d791862 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt @@ -11,17 +11,17 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import com.owncloud.android.operations.UploadFileOperation -import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen class FileUploaderIntents(private val context: Context) { fun openUploadListIntent(operation: UploadFileOperation?): PendingIntent { - val intent = UploadListActivity.createIntent( - operation?.file, - operation?.user, - Intent.FLAG_ACTIVITY_CLEAR_TOP, - context - ) + val intent = NavigatorActivity.intent(context, NavigatorScreen.UploadList).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(FileActivity.EXTRA_USER, operation?.user) + } return PendingIntent.getActivity( context, diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt index e7f7d987de16..bdd9f88fa0aa 100644 --- a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt @@ -18,7 +18,8 @@ import androidx.core.app.NotificationManagerCompat import com.nextcloud.client.notifications.action.SyncConflictNotificationBroadcastReceiver import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen import com.owncloud.android.ui.notifications.NotificationUtils /** @@ -38,7 +39,7 @@ object AppWideNotificationManager { private const val SYNC_CONFLICT_NOTIFICATION_ID = 112 fun getUploadListPendingIntent(context: Context): PendingIntent { - val intent = Intent(context, UploadListActivity::class.java).apply { + val intent = NavigatorActivity.intent(context, NavigatorScreen.UploadList).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } diff --git a/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt b/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt index db067a497b9e..4d0e1e67a4e4 100644 --- a/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt +++ b/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt @@ -11,7 +11,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.NotificationManagerCompat -import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen class SyncConflictNotificationBroadcastReceiver : BroadcastReceiver() { companion object { @@ -25,7 +26,7 @@ class SyncConflictNotificationBroadcastReceiver : BroadcastReceiver() { NotificationManagerCompat.from(context).cancel(notificationId) } - val intent = Intent(context, UploadListActivity::class.java).apply { + val intent = NavigatorActivity.intent(context, NavigatorScreen.UploadList).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } context.startActivity(intent) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index a702779b6cf2..6de0b1743103 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -629,7 +629,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) { showOnDeviceFiles(); } else if (itemId == R.id.nav_uploads) { resetOnlyPersonalAndOnDevice(); - startActivity(UploadListActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP); + pushFragment(NavigatorScreen.UploadList.INSTANCE); } else if (itemId == R.id.nav_trashbin) { resetOnlyPersonalAndOnDevice(); pushFragment(NavigatorScreen.Trashbin.INSTANCE); diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt index e9c710087e83..71ea3dfa7906 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt @@ -42,7 +42,7 @@ import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.DrawerActivity import com.owncloud.android.ui.adapter.progressListener.UploadProgressListener import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper import com.owncloud.android.ui.adapter.uploadList.helper.UploadListItemOnClick @@ -66,7 +66,7 @@ import java.util.function.Consumer "ReturnCount" ) class UploadListAdapter( - private val activity: FileActivity, + private val activity: DrawerActivity, private val fileDataStorageManager: FileDataStorageManager, private val uploadsStorageManager: UploadsStorageManager, private val accountManager: UserAccountManager, @@ -459,7 +459,7 @@ class UploadListAdapter( val user = optionalUser.get() if (item.lastResult == UploadResult.CREDENTIAL_ERROR) { - activity.fileOperationsHelper.checkCurrentCredentials(user) + itemOnClick.onCredentialErrorClick(user) } else if (item.isLastResultConflictError()) { itemOnClick.onLastUploadResultConflictClick(item) } else { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt index d2732f3f8a4e..bb3ef838b76e 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt @@ -17,14 +17,14 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.ConflictsResolveActivity -import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.DrawerActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.preview.PreviewImageFragment import com.owncloud.android.utils.MimeType import com.owncloud.android.utils.MimeTypeUtil import java.io.File -class UploadListAdapterHelper(private val activity: FileActivity) { +class UploadListAdapterHelper(private val activity: DrawerActivity) { companion object { private const val TAG = "UploadListAdapterHelper" @@ -33,7 +33,7 @@ class UploadListAdapterHelper(private val activity: FileActivity) { fun openConflictActivity(file: OCFile, upload: OCUpload) { file.setStoragePath(upload.localPath) - val user = activity.accountManager.getUser(upload.accountName) + val user = activity.userAccountManager.getUser(upload.accountName) user.ifPresent { val intent = ConflictsResolveActivity.createIntent( file, diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt index 4243adb86006..d6c5b69b6562 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt @@ -7,8 +7,10 @@ package com.owncloud.android.ui.adapter.uploadList.helper +import com.nextcloud.client.account.User import com.owncloud.android.db.OCUpload interface UploadListItemOnClick { fun onLastUploadResultConflictClick(upload: OCUpload) + fun onCredentialErrorClick(user: User) } diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/NavigatorScreen.kt b/app/src/main/java/com/owncloud/android/ui/navigation/NavigatorScreen.kt index 7211bc869faa..0930c5c71bd0 100644 --- a/app/src/main/java/com/owncloud/android/ui/navigation/NavigatorScreen.kt +++ b/app/src/main/java/com/owncloud/android/ui/navigation/NavigatorScreen.kt @@ -13,6 +13,7 @@ import com.owncloud.android.R import com.owncloud.android.ui.fragment.ActivitiesFragment import com.owncloud.android.ui.fragment.community.CommunityFragment import com.owncloud.android.ui.fragment.notifications.NotificationsFragment +import com.owncloud.android.ui.fragment.uploadList.UploadListFragment import com.owncloud.android.ui.navigation.model.ActionBarStyle import com.owncloud.android.ui.trashbin.TrashbinFragment import kotlinx.parcelize.Parcelize @@ -31,17 +32,22 @@ sealed class NavigatorScreen(val tag: String, val hasDrawer: Boolean = true) : P @Parcelize object Trashbin : NavigatorScreen(TRASHBIN_TAG) + @Parcelize + object UploadList : NavigatorScreen(UPLOAD_LIST_TAG) + companion object { private const val TRASHBIN_TAG = "Trashbin" private const val ACTIVITIES_TAG = "Activities" private const val COMMUNITY_TAG = "Community" private const val NOTIFICATIONS_TAG = "Notifications" + private const val UPLOAD_LIST_TAG = "UploadList" fun fromTag(tag: String?): NavigatorScreen? = when (tag) { ACTIVITIES_TAG -> Activities COMMUNITY_TAG -> Community NOTIFICATIONS_TAG -> Notifications TRASHBIN_TAG -> Trashbin + UPLOAD_LIST_TAG -> UploadList else -> null } } @@ -50,6 +56,7 @@ sealed class NavigatorScreen(val tag: String, val hasDrawer: Boolean = true) : P Community -> R.id.nav_community Activities -> R.id.nav_activity Trashbin -> R.id.nav_trashbin + UploadList -> R.id.nav_uploads Notifications -> -1 } @@ -57,6 +64,7 @@ sealed class NavigatorScreen(val tag: String, val hasDrawer: Boolean = true) : P Community -> ActionBarStyle.Plain to R.string.drawer_community Activities -> ActionBarStyle.Plain to R.string.drawer_item_activities Trashbin -> ActionBarStyle.Plain to R.string.drawer_item_trashbin + UploadList -> ActionBarStyle.Plain to R.string.uploads_view_title Notifications -> ActionBarStyle.Plain to R.string.drawer_item_notifications } @@ -65,5 +73,6 @@ sealed class NavigatorScreen(val tag: String, val hasDrawer: Boolean = true) : P Activities -> ActivitiesFragment() Notifications -> NotificationsFragment() Trashbin -> TrashbinFragment() + UploadList -> UploadListFragment() } } From 12a0a1da8f46a3ddfc3778323c3e9b28eff72d78 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 12:43:42 +0200 Subject: [PATCH 5/7] update callers Signed-off-by: alperozturk96 --- .../ui/fragment/uploadList/UploadListCredentialsHandler.kt | 2 +- .../com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt index 69f0e3720621..a165a9f4fe88 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListCredentialsHandler.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.owncloud.android.ui.fragment.uploadList diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt b/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt index ab56f6c4afda..53244f46d8f2 100644 --- a/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/helpers/CredentialsUpdateHelper.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ From 2022d27f61772dea3588544b2c484d04b6f2f4fd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 14:50:34 +0200 Subject: [PATCH 6/7] add missing licenses Signed-off-by: alperozturk96 --- .../assets/worked-example.md | 7 +++++++ .../references/ACTIVITY-TO-FRAGMENT-MAPPING.md | 7 +++++++ .../references/ARCHITECTURE.md | 7 +++++++ .../references/HOST-CAPABILITIES.md | 7 +++++++ .../references/PITFALLS.md | 7 +++++++ 5 files changed, 35 insertions(+) diff --git a/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md b/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md index df91ef882dfa..9568ec0dc97b 100644 --- a/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md +++ b/.claude/skills/android-activity-to-navigator-fragment/assets/worked-example.md @@ -1,3 +1,10 @@ + + # Worked Example: `UploadListActivity` → `UploadListFragment` This conversion was the hardest one so far, because the Activity extended `FileActivity`. diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md b/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md index af5128469ed5..46257a22c236 100644 --- a/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md +++ b/.claude/skills/android-activity-to-navigator-fragment/references/ACTIVITY-TO-FRAGMENT-MAPPING.md @@ -1,3 +1,10 @@ + + # Activity API → Navigator Fragment Equivalent | In the Activity | In the fragment | diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md b/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md index 6a3def4ba1df..f9076a999e19 100644 --- a/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md +++ b/.claude/skills/android-activity-to-navigator-fragment/references/ARCHITECTURE.md @@ -1,3 +1,10 @@ + + # Navigator Architecture All classes live in `app/src/main/java/com/owncloud/android/ui/navigation/`. diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md b/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md index f02219cfeadc..c236a260f7c0 100644 --- a/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md +++ b/.claude/skills/android-activity-to-navigator-fragment/references/HOST-CAPABILITIES.md @@ -1,3 +1,10 @@ + + # What `NavigatorActivity` Provides `NavigatorActivity` extends `DrawerActivity → ToolbarActivity → BaseActivity`. It does diff --git a/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md b/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md index 655eaff60134..1397186ade7f 100644 --- a/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md +++ b/.claude/skills/android-activity-to-navigator-fragment/references/PITFALLS.md @@ -1,3 +1,10 @@ + + # Pitfalls Found in Earlier Conversions ## Bare intents crash the host (#17193) From 7c803770ee9c2864a654348b4c1e544ea56891ee Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 2 Oct 2026 16:06:23 +0200 Subject: [PATCH 7/7] remove unused functions Signed-off-by: alperozturk96 --- .../owncloud/android/ui/activity/DrawerActivity.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 6de0b1743103..6ac40163ec51 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -14,7 +14,6 @@ import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; -import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; @@ -736,16 +735,6 @@ private void startAssistantScreen() { startActivity(composeActivity); } - void startActivity(Class activity) { - startActivity(new Intent(getApplicationContext(), activity)); - } - - private void startActivity(Class activity, int flags) { - Intent intent = new Intent(getApplicationContext(), activity); - intent.setFlags(flags); - startActivity(intent); - } - public void showManageAccountsDialog() { ChooseAccountDialogFragment choseAccountDialog = ChooseAccountDialogFragment.newInstance(accountManager.getUser()); choseAccountDialog.show(getSupportFragmentManager(), "fragment_chose_account");