Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .claude/skills/android-eventbus-to-workmanager-flow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
name: android-eventbus-to-workmanager-flow
description: >
Use when replacing a GreenRobot EventBus event that a WorkManager worker posts (progress,
state, completion) with WorkManager's own observation APIs, consumed through a ViewModel
Flow; when the user mentions "event bus", "EventBus", "EventBusFactory", "@Subscribe",
"worker progress", "setProgress", "observe worker", "getWorkInfosForUniqueWorkFlow" or
"listen to the worker through the viewmodel". Covers publishing progress from the worker,
building the ViewModel Flow keyed by the work name, DI registration, collecting the Flow
from Kotlin or legacy Java fragments/activities, and deleting the event classes.
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"
---

# EventBus → WorkManager Flow via ViewModel

Workers used to tell the UI what they were doing by posting GreenRobot EventBus events
(`EventBusFactory.downloadProgressEventBus.post(FileDownloadProgressEvent(percent))`). That
has three problems: the event is global (every subscriber gets every worker's events,
whatever file it is about), it is lost when nobody is registered, and the subscriber has to
register and unregister by hand in `onStart`/`onStop`.

WorkManager already stores each worker's state and progress and exposes them as `Flow`s. The
UI asks WorkManager about **one specific piece of work**, through a ViewModel, and collects
the Flow with the lifecycle. Official guide:
https://developer.android.com/develop/background-work/background-tasks/persistent/how-to/observe

Converted so far: `FileDownloadProgressEvent` → `FileDetailFragmentViewModel.downloadProgress`
(see `assets/worked-example.md`). The earlier `FileDisplayActivityViewModel.observeOfflineWorker`
is the simplest form of the same pattern (state only, Kotlin consumer).

## The Prime Directive: Behaviour Must Not Change

The screen must show the same thing at the same moments as before. The only intended
differences are the ones the global bus got wrong: a screen now only reacts to the work it is
about. Write them down for the PR description. If you find an unrelated bug, report it
instead of fixing it in the same change.

## Workflow

### Step 0: Inventory (do not edit yet)

1. `git grep -n "<EventName>"` across `app/src`. List every **poster** (usually a worker) and
every **subscriber** (`@Subscribe fun on…(event: <EventName>)`).
2. If the event lives on a dedicated bus (`EventBusFactory.<x>EventBus`), also grep for the
bus. When it has no other users, delete it together with the event.
3. For each poster, find how its work is enqueued in `BackgroundJobManagerImpl`: the
**unique work name** (`enqueueUniqueWork(name, …)`) and the **tags** (`oneTimeRequestBuilder`
adds `formatNameTag`, `formatClassTag`, `formatUserTag`, plus any custom tag). The UI
must be able to rebuild exactly one of these from what it already knows (user, file id…).
4. List what each event field carries and what the subscriber does with it. Decide whether
it is **progress** (while running → `setProgress`), a **result** (at the end →
`Result.success(outputData)`), or only **state** (`WorkInfo.state`, nothing to publish).
5. Look for older parallel mechanisms the subscriber also uses for the same purpose (bound
listeners, `LocalBroadcastManager`). If you can prove one is dead (e.g. a field that is
only ever set to `null`), remove it with all its plumbing, including test doubles in
`app/src/debug` and mocks in `androidTest`. Otherwise leave it and mention it in the summary.

### Step 1: Make the work addressable

If the unique work name / tag builder is `private` in `BackgroundJobManagerImpl`, move it
into the `companion object` next to `formatNameTag`/`formatClassTag` as a public
`format…Tag(...)` function, and have the enqueue and cancel call sites use it. The UI and
the scheduler must build the name with the same function, never with a copied string.

### Step 2: Publish from the worker

- Add the `Data` key as a `const val` in the worker's `companion object`
(`const val PROGRESS_PERCENT = "PROGRESS_PERCENT"`).
- From a `suspend` context call `setProgress(workDataOf(KEY to value))`. From a non-suspend
callback (e.g. `OnDatatransferProgressListener.onTransferProgress`) call
`setProgressAsync(...)`.
- **Throttle it.** WorkManager writes progress to its database. Publish only when the value
changed, and reuse an existing throttle (the notification update interval) when the
worker has one.
- Results go in `Result.success(workDataOf(...))` and are read from `WorkInfo.outputData`.
- Delete the `post(...)` call and the event imports.

### Step 3: ViewModel

One ViewModel per screen, in a package next to the screen
(`ui/fragment/<screen>/<Screen>FragmentViewModel.kt`, mirroring
`ui/activity/filedisplayactivity/FileDisplayActivityViewModel.kt`). Template:
`references/VIEWMODEL-TEMPLATE.md`.

- Constructor-inject `WorkManager` (`@Inject constructor(private val workManager: WorkManager)`).
- Hold the work name the screen is interested in a `MutableStateFlow<String?>`. A setter
function (`observeDownloadProgress(user, file)`) builds it with the Step 1 function.
- Expose a **cold** `Flow<T>`: `filterNotNull()` → `flatMapLatest { getWorkInfosForUniqueWorkFlow(it) }`
→ map the `WorkInfo` list to the UI value → `distinctUntilChanged()`. Use
`getWorkInfoByIdFlow(id)` when you have the request id, `getWorkInfosByTagFlow(tag)` when
the work is not unique.
- Progress is only present while `state == RUNNING`. It is empty after the work finishes, so
filter on `RUNNING` and drop missing keys (`progress.keyValueMap[KEY] as? Int`).
- Register it in `di/ViewModelModule.kt` with `@Binds @IntoMap @ViewModelKey(X::class)`.
Without this, `ViewModelFactory` throws at runtime.

### Step 4: Consumer

- Get the ViewModel from `ViewModelFactory`: Kotlin `by viewModels { vmFactory }`, Java
`new ViewModelProvider(this, vmFactory).get(X.class)` in `onCreate`. Injection of
`Injectable` fragments happens before `onAttach`, so `vmFactory` is ready in `onCreate`.
- Tell the ViewModel what to watch where the screen learns its subject (`onViewCreated`,
and every public "show this other file" method such as `updateFileDetails(file, user)`).
- Collect with the **view** lifecycle in fragments:
- Kotlin: `viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(STARTED) { vm.flow.collect { … } } }`
- Java: `LifecycleOwnerExtensionsKt.collectWhenStarted(getViewLifecycleOwner(), viewModel.getX(), this::onX);`
(`utils/extensions/LifecycleOwnerExtensions.kt`). Do not switch to `LiveData` for Java
callers. See `references/PITFALLS.md` for why.
- Turn the old `@Subscribe public void onX(Event e)` into `private void onX(T value)`. Keep
its body unchanged apart from reading the value directly.
- Remove the `register`/`unregister` lines for the bus. Keep `EventBus.getDefault()` registration
if the class still has other `@Subscribe` methods.

### Step 5: Delete and verify

1. Delete the event class (and the dedicated bus if it has no users left).
2. `git grep -n "<EventName>\|<busName>"` returns nothing.
3. `./gradlew :app:compileGplayDebugJavaWithJavac spotlessKotlinCheck detekt`, then
`lintGplayDebug spotbugsGplayDebug`. Fix findings in every file you touched.
4. Manual check (hand it to the developer if you cannot run the app): start the work and
open the screen, so the value shows up; open the screen first, then start the work; rotate
while it runs; leave the screen for more than 5 s, let the work finish, come back (no stale
value); open the screen for a *different* subject while work runs for another one (it must
not react).

## References

- `references/VIEWMODEL-TEMPLATE.md`: ViewModel, worker and consumer snippets.
- `references/PITFALLS.md`: what went wrong or nearly went wrong.
- `assets/worked-example.md`: `FileDownloadProgressEvent` → `FileDetailFragmentViewModel`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
~ SPDX-License-Identifier: AGPL-3.0-or-later
-->

# Worked Example: `FileDownloadProgressEvent` → `FileDetailFragmentViewModel`

## Inventory findings

- Event: `ui/events/FileDownloadProgressEvent(percent: Int)` on a dedicated bus,
`ui/events/EventBusFactory.downloadProgressEventBus`. Neither had other users.
- Poster: `FileDownloadWorker.onTransferProgress`, on every transfer chunk, unthrottled, with
no file information.
- Subscriber: `FileDetailFragment` (Java). It registered the bus in `onStart`, unregistered
it in `onStop`, and `@Subscribe(threadMode = MAIN) onDownloadProgress` showed the progress
block with the "downloading" text and the percent.
- Enqueue: `BackgroundJobManagerImpl.startFileDownloadJob` →
`enqueueUniqueWork(startFileDownloadJobTag(accountName, fileId), KEEP, request)`, and the
same string as a tag. The builder was `private`.
- Parallel mechanism: `containerActivity.getFileDownloadProgressListener()` /
`FileDownloadWorker.FileDownloadProgressListener`. The field in `FileActivity` is never
assigned, so it is always `null`, which is why the bus was added (commit `37df0dca9d`).
Removed in the same change, since the ViewModel replaces it.
- Folders use `FolderDownloadWorker`, which never posted the event.

## Changes

| File | Change |
|---|---|
| `jobs/BackgroundJobManagerImpl.kt` | `startFileDownloadJobTag` → public companion `formatFileDownloadTag`, used by start and cancel |
| `jobs/download/FileDownloadWorker.kt` | `PROGRESS_PERCENT` key; `setProgressAsync(workDataOf(PROGRESS_PERCENT to percent))` inside the notification throttle; bus post removed |
| `ui/fragment/filedetail/FileDetailFragmentViewModel.kt` | new; `downloadProgress: Flow<Int>` keyed by `formatFileDownloadTag` |
| `di/ViewModelModule.kt` | `@Binds @IntoMap @ViewModelKey(FileDetailFragmentViewModel::class)` |
| `utils/extensions/LifecycleOwnerExtensions.kt` | new; `collectWhenStarted(flow, Consumer)` for Java consumers |
| `ui/fragment/FileDetailFragment.java` | `ViewModelFactory` inject, VM in `onCreate`, `observeDownloadProgress` in `onViewCreated` and `updateFileDetails(file, user)`, `collectWhenStarted` on the view lifecycle, `@Subscribe` → `private void onDownloadProgress(int)`, bus register/unregister removed |
| `ui/events/FileDownloadProgressEvent.kt`, `ui/events/EventBusFactory.kt` | deleted |
| `FileDownloadWorker.FileDownloadProgressListener` and its plumbing | deleted: inner class, worker field, `ComponentsGetter.getFileDownloadProgressListener()`, `FileActivity` field and getter, the two `= null` assignments in `FileDisplayActivity`, the download branches of `FileDetailFragment.listenForTransferProgress`/`leaveTransferProgress`, the `TestActivity` override and the `FileMenuFilterIT` mock |

## Intended behaviour differences

- Progress is shown only for the file the details screen is about, not for whichever file is
downloading.
- The UI updates at the notification throttle (750 ms) instead of on every chunk.

## Decisions

- **Cold `Flow` + `collectWhenStarted`, not `LiveData`.** `LiveData` replayed a stale
percent after a finish that happened while the view was stopped (see `PITFALLS.md`).
- **The ViewModel is created in `onCreate`, not `onViewCreated`.** The activity can call
`updateFileDetails(file, user)` before the view exists, and the ViewModel must survive view
re-creation from the back stack.
- **The finished state is still handled by the existing broadcast path.**
`FileDisplayActivity` calls `updateFileDetails(false, true)` on `ACTION_DOWNLOAD_COMPLETED`.
Moving that to `WorkInfo.State.SUCCEEDED` is a possible follow-up, not part of this change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
~ SPDX-License-Identifier: AGPL-3.0-or-later
-->

# Pitfalls

## `LiveData` replays a stale value

The first draft exposed `asLiveData()` so the Java fragment could `observe(...)`. `LiveData`
keeps its last value and stops collecting upstream 5 s after the last observer goes away. If
the download finishes during those 5 s, the cached value is the last running percent, and
the next observer gets it right away. The progress block is shown again for a download that
has already finished. A cold `Flow` collected with `repeatOnLifecycle` asks WorkManager again
on every start and only gets the current state. Use `collectWhenStarted` from Java instead
of `LiveData`.

## Progress disappears when the work finishes

`WorkInfo.progress` is empty as soon as the state leaves `RUNNING`. Do not read progress
from `SUCCEEDED`/`FAILED` infos, and do not treat the missing key as `0`. Use `mapNotNull`
and let the existing "finished" path (broadcast, `updateFileDetails(false, true)`) hide the
UI, as it did before. Put final values in `Result.success(outputData)` if the UI needs them.

## Every `setProgress` is a database write

WorkManager persists progress. Calling it on every transfer chunk writes to the database
hundreds of times per file. Publish only on change and inside the worker's existing
throttle (`minProgressUpdateInterval`).

## Unique work name vs tag

`getWorkInfosForUniqueWorkFlow(name)` matches the `enqueueUniqueWork` name.
`getWorkInfosByTagFlow(tag)` matches `addTag`. In `startFileDownloadJob` the same string
is used for both, but that is not true for every job. Check the enqueue call. Never build the
string by hand in the ViewModel. Make the builder public in the
`BackgroundJobManagerImpl` companion.

## The bus was global; the Flow is not

`FileDownloadProgressEvent` reached `FileDetailFragment` for **any** running download, so
the details of file A could show the progress of file B. The ViewModel is keyed by
account + file id, so it only reacts to its own file. This is the intended change; mention it
in the PR. Check which worker really runs for each subject: folders are downloaded by
`FolderDownloadWorker`, not `FileDownloadWorker`, and never posted the event.

## The subject can change without a new view

`FileDetailFragment.updateFileDetails(OCFile, User)` swaps the file on a live fragment. Call
`viewModel.observeX(...)` there too. Thanks to `flatMapLatest`, the old work's Flow is
cancelled automatically. Guard with `viewModel != null` because the activity can call it on a
fragment that has not reached `onCreate` yet.

## Missing ViewModel binding

Forgetting `@Binds @IntoMap @ViewModelKey(X::class)` in `ViewModelModule` compiles, but
`ViewModelFactory` throws `IllegalArgumentException` when the screen opens.

## Keep other `@Subscribe` methods registered

`FileDetailFragment` still receives `FavoriteEvent` on `EventBus.getDefault()`. Remove only
the registration for the bus you are replacing. Removing the default registration silently
stops the remaining events. The other way round fails too: if the last `@Subscribe` method
goes away but `register(this)` stays, EventBus throws `EventBusException` ("has no public
methods with the @Subscribe annotation") when the screen starts.
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
~ SPDX-License-Identifier: AGPL-3.0-or-later
-->

# Templates

## Worker

```kotlin
companion object {
const val PROGRESS_PERCENT = "PROGRESS_PERCENT"
}

// suspend context
setProgress(workDataOf(PROGRESS_PERCENT to percent))

// non-suspend callback, inside the existing throttle
if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) {
notificationManager.updateDownloadProgress(percent, totalToTransfer)
setProgressAsync(workDataOf(PROGRESS_PERCENT to percent))
lastUpdateTime = currentTime
}
```

## Work name (BackgroundJobManagerImpl companion)

```kotlin
fun formatFileDownloadTag(accountName: String, fileId: Long): String =
JOB_FOLDER_DOWNLOAD + accountName + fileId
```

Use it in `startXJob` (`enqueueUniqueWork(tag, …)`), in `cancelXJob`, and in the ViewModel.

## ViewModel

```kotlin
class FileDetailFragmentViewModel @Inject constructor(private val workManager: WorkManager) : ViewModel() {

private val downloadWorkName = MutableStateFlow<String?>(null)

@OptIn(ExperimentalCoroutinesApi::class)
val downloadProgress: Flow<Int> = downloadWorkName
.filterNotNull()
.flatMapLatest { workManager.getWorkInfosForUniqueWorkFlow(it) }
.mapNotNull { workInfos -> workInfos.firstOrNull { it.state == WorkInfo.State.RUNNING }?.downloadPercent() }
.distinctUntilChanged()

fun observeDownloadProgress(user: User, file: OCFile) {
downloadWorkName.value = BackgroundJobManagerImpl.formatFileDownloadTag(user.accountName, file.fileId)
}

private fun WorkInfo.downloadPercent(): Int? = progress.keyValueMap[FileDownloadWorker.PROGRESS_PERCENT] as? Int
}
```

State-only variant (no progress published), from `FileDisplayActivityViewModel`:

```kotlin
suspend fun observeOfflineWorker(onComplete: () -> Unit) {
workManager
.getWorkInfosByTagFlow(BackgroundJobManagerImpl.formatClassTag(OfflineOperationsWorker::class))
.collect { workInfos ->
if (workInfos.any { it.state == WorkInfo.State.SUCCEEDED }) {
onComplete()
}
}
}
```

Prefer exposing a `Flow` property over a `suspend fun` with a callback: the consumer decides
the lifecycle, and Java can collect it through `collectWhenStarted`.

## DI (`di/ViewModelModule.kt`)

```kotlin
@Binds
@IntoMap
@ViewModelKey(FileDetailFragmentViewModel::class)
abstract fun fileDetailFragmentViewModel(vm: FileDetailFragmentViewModel): ViewModel
```

## Kotlin consumer (fragment)

```kotlin
@Inject lateinit var vmFactory: ViewModelFactory
private val viewModel by viewModels<FileDetailFragmentViewModel> { vmFactory }

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.observeDownloadProgress(user, file)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.downloadProgress.collect(::onDownloadProgress)
}
}
}
```

## Java consumer (fragment)

```java
@Inject ViewModelFactory vmFactory;
private FileDetailFragmentViewModel viewModel;

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this, vmFactory).get(FileDetailFragmentViewModel.class);
}

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
viewModel.observeDownloadProgress(user, getFile());
LifecycleOwnerExtensionsKt.collectWhenStarted(getViewLifecycleOwner(),
viewModel.getDownloadProgress(),
this::onDownloadProgress);
}

private void onDownloadProgress(int percent) { … }
```

`collectWhenStarted` (`utils/extensions/LifecycleOwnerExtensions.kt`) wraps
`lifecycleScope.launch { repeatOnLifecycle(STARTED) { flow.collect(collector::accept) } }`
and takes an `androidx.core.util.Consumer`, so a Java method reference fits.
Loading
Loading