Refresh Files list automatically when an upload finishes
Build APK / build (push) Successful in 5m32s

Uploads run entirely in ShareUploadService.kt, an Android foreground
service with no channel back to Dart once started - by design, so
closing the app mid-upload doesn't interrupt it. That meant
FilesController had no way to know a batch had finished, so a newly
uploaded file only appeared after a manual pull-to-refresh.

Adds a one-shot completion signal instead of a full stream: the
service publishes into a new UploadEventBus (in-process pub/sub,
mirroring SyncStatusBus) once a batch finishes with at least one
success, MainActivity forwards it to Dart over a new
dev.ayushya.noo/upload_service/status EventChannel, and
FilesController (subscribed in its own constructor, same pattern
OfflineController already uses for sync completion) calls
refreshData() when the event's destination folder matches
currentFolderPath. Covers both upload entry points (share-to-Noo and
Files' own "+" -> Upload file), since they already share the same
ShareUploadView/UploadService path.

Move/Copy needed no fix - ItemOperations already calls
files.invalidateCache() + refreshData() on success.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:09:08 -04:00
co-authored by Claude Sonnet 5
parent b73f451fff
commit c18fc98dc3
6 changed files with 146 additions and 8 deletions
+20 -7
View File
@@ -206,13 +206,26 @@ rather than needing a rewrite for multi-account support.
Dio from a separate process lifecycle - `UploadService.startUpload` passes Dio from a separate process lifecycle - `UploadService.startUpload` passes
everything the Kotlin side needs (the pre-built `Authorization` header everything the Kotlin side needs (the pre-built `Authorization` header
from `NextcloudService.authHeaders`, not the raw password) as Intent from `NextcloudService.authHeaders`, not the raw password) as Intent
extras, a one-way handoff with no channel back to Dart afterward. Keep extras, a one-way handoff for the start of the upload - the Kotlin side
the two upload implementations in sync manually if upload semantics never asks Dart anything mid-upload, since the app may not even be
change. Progress/cancellation is entirely notification-driven (one running by then. Keep the two upload implementations in sync manually if
ongoing, updatable notification for the whole batch; its Cancel action upload semantics change. Progress/cancellation is entirely notification-
re-delivers an Intent to the same running service instance, which an driven (one ongoing, updatable notification for the whole batch; its
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to Cancel action re-delivers an Intent to the same running service instance,
the Dart UI, by design, since the app may not even be running. which an `AtomicBoolean` the copy/upload loops poll) - the notification is
the only UI a closed app gets.
There is one thing that *does* come back, when the app is still running:
once a batch finishes with at least one success, `ShareUploadService.kt`
publishes into `UploadEventBus` (an in-process pub/sub, same shape as
`SyncStatusBus` below), which `MainActivity.kt` forwards to Dart over the
`dev.ayushya.noo/upload_service/status` `EventChannel` -
`UploadService.completions`. `FilesController` subscribes in its own
constructor and calls `refreshData()` when the event's folder matches
`currentFolderPath`, so a file uploaded into the folder currently on
screen shows up without a manual pull-to-refresh. Follows
`SyncService.statusStream`'s "one shared `static final` stream" rule (see
its own doc comment) - a second `receiveBroadcastStream()` subscriber
would silently steal the single native-side listener from the first.
## Being picked by other apps (photo/file picker) ## Being picked by other apps (photo/file picker)
@@ -56,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() {
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new" private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
private val syncServiceChannelName = "dev.ayushya.noo/sync_service" private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status" private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
private val uploadStatusChannelName = "dev.ayushya.noo/upload_service/status"
private val notificationPermissionRequestCode = 4202 private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the // Lazy, not a field initializer - `packageName` reads through the
// Activity's base Context, which isn't attached yet while this class's // Activity's base Context, which isn't attached yet while this class's
@@ -66,6 +67,7 @@ class MainActivity : FlutterFragmentActivity() {
private var newShareSink: EventChannel.EventSink? = null private var newShareSink: EventChannel.EventSink? = null
private var newPickSink: EventChannel.EventSink? = null private var newPickSink: EventChannel.EventSink? = null
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
private var uploadCompletedListener: ((UploadEventBus.Completed) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -157,6 +159,29 @@ class MainActivity : FlutterFragmentActivity() {
syncStatusListener = null syncStatusListener = null
} }
}) })
EventChannel(flutterEngine.dartExecutor.binaryMessenger, uploadStatusChannelName)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val listener: (UploadEventBus.Completed) -> Unit = { completed ->
mainHandler.post {
events.success(
mapOf(
"remoteFolder" to completed.remoteFolder,
"succeeded" to completed.succeeded,
"failed" to completed.failed,
),
)
}
}
uploadCompletedListener = listener
UploadEventBus.subscribe(listener)
}
override fun onCancel(arguments: Any?) {
uploadCompletedListener?.let { UploadEventBus.unsubscribe(it) }
uploadCompletedListener = null
}
})
} }
/// [accountIdOverride] is the account Dart is asking about - needed /// [accountIdOverride] is the account Dart is asking about - needed
@@ -185,6 +185,13 @@ class ShareUploadService : Service() {
else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed" else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed"
} }
manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText)) manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText))
// batch.remoteFolder, not the locally-normalized cleanFolder - Dart's
// FilesController.currentFolderPath never carries the trailing
// slash cleanFolder adds, so publishing the original value lets the
// Dart side compare them directly with no reformatting of its own.
if (succeeded > 0) {
UploadEventBus.publishCompleted(batch.remoteFolder, succeeded, failed)
}
if (queue.pendingCount == 0) { if (queue.pendingCount == 0) {
stopForeground(STOP_FOREGROUND_DETACH) stopForeground(STOP_FOREGROUND_DETACH)
@@ -0,0 +1,39 @@
package dev.ayushya.noo
/**
* In-memory, in-process pub/sub for "an upload batch just finished" -
* [ShareUploadService] publishes into this, `MainActivity`'s
* `dev.ayushya.noo/upload_service/status` EventChannel forwards it to Dart
* so `FilesController` can refresh the destination folder if it's the one
* currently on screen, without a manual pull-to-refresh. See
* [SyncStatusBus]'s identical shape/rationale - the service and the
* Activity always run in the same process, so plain in-memory pub/sub is
* enough. Unlike that bus, this only ever carries discrete "just finished"
* events, not ongoing state, so there's nothing to replay to a listener
* that subscribes late.
*/
object UploadEventBus {
data class Completed(
val remoteFolder: String,
val succeeded: Int,
val failed: Int,
)
private val listeners = mutableListOf<(Completed) -> Unit>()
@Synchronized
fun subscribe(listener: (Completed) -> Unit) {
listeners.add(listener)
}
@Synchronized
fun unsubscribe(listener: (Completed) -> Unit) {
listeners.remove(listener)
}
@Synchronized
fun publishCompleted(remoteFolder: String, succeeded: Int, failed: Int) {
val event = Completed(remoteFolder, succeeded, failed)
listeners.toList().forEach { it(event) }
}
}
+18
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../services/upload_service.dart';
import 'folder_browser.dart'; import 'folder_browser.dart';
import 'session_controller.dart'; import 'session_controller.dart';
@@ -107,6 +108,22 @@ class FilesController extends ChangeNotifier
session.addAccountClearedListener(_onAccountCleared); session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated); session.addAccountActivatedListener(_onAccountActivated);
session.addAccountReadyListener(_restoreDisplayPrefs); session.addAccountReadyListener(_restoreDisplayPrefs);
// Refreshes automatically once an upload finishes, so a file shared/
// uploaded into the folder currently on screen shows up without a
// manual pull-to-refresh - uploads run in an Android foreground
// service (see UploadService's doc comment) with no other way back to
// this controller.
_uploadSub = UploadService.completions.listen(_onUploadCompleted);
}
StreamSubscription<UploadCompletion>? _uploadSub;
void _onUploadCompleted(UploadCompletion completion) {
// refreshData() always hits the network regardless of cache freshness
// (see its own doc comment), so there's nothing to invalidate first -
// it overwrites this folder's cache entry once the fetch lands.
if (completion.remoteFolder != _currentFolderPath) return;
refreshData();
} }
// Getters // Getters
@@ -711,6 +728,7 @@ class FilesController extends ChangeNotifier
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_cacheRefreshTimer?.cancel(); _cacheRefreshTimer?.cancel();
_uploadSub?.cancel();
super.dispose(); super.dispose();
} }
} }
+37 -1
View File
@@ -5,6 +5,27 @@ import '../providers/session_controller.dart';
import 'native_channel.dart'; import 'native_channel.dart';
import 'share_intent_service.dart'; import 'share_intent_service.dart';
/// One upload batch finishing - see [UploadService.completions].
class UploadCompletion {
final String remoteFolder;
final int succeeded;
final int failed;
const UploadCompletion({
required this.remoteFolder,
required this.succeeded,
required this.failed,
});
factory UploadCompletion.fromMap(Map<dynamic, dynamic> map) {
return UploadCompletion(
remoteFolder: map['remoteFolder'] as String? ?? '/',
succeeded: (map['succeeded'] as num?)?.toInt() ?? 0,
failed: (map['failed'] as num?)?.toInt() ?? 0,
);
}
}
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android /// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android
/// foreground service, which does the actual prepare+upload entirely on its /// foreground service, which does the actual prepare+upload entirely on its
/// own from there - including surviving the app being closed - with a /// own from there - including surviving the app being closed - with a
@@ -14,11 +35,15 @@ import 'share_intent_service.dart';
/// real Android Service does. /// real Android Service does.
class UploadService { class UploadService {
static const _channel = MethodChannel('dev.ayushya.noo/upload_service'); static const _channel = MethodChannel('dev.ayushya.noo/upload_service');
static const _statusChannel = EventChannel(
'dev.ayushya.noo/upload_service/status',
);
/// Starts uploading [files] into the active account's current folder. /// Starts uploading [files] into the active account's current folder.
/// Fire-and-forget from Dart's perspective - once this returns, the /// Fire-and-forget from Dart's perspective - once this returns, the
/// service owns the rest and reports progress/completion/cancellation /// service owns the rest and reports progress/completion/cancellation
/// through its own notification, not back to the app. /// through its own notification, not back to the app. [completions] is
/// the one thing it does report back, for `FilesController` to act on.
static Future<void> startUpload( static Future<void> startUpload(
SessionController session, SessionController session,
FilesController files, FilesController files,
@@ -37,4 +62,15 @@ class UploadService {
'remoteFolder': files.currentFolderPath, 'remoteFolder': files.currentFolderPath,
}); });
} }
/// Fires once per finished batch (at least one file uploaded) from
/// `UploadEventBus` (Kotlin) - lets `FilesController` refresh the
/// destination folder if it's the one currently on screen, instead of the
/// list only updating on a manual pull-to-refresh. One shared stream,
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
/// `EventChannel.receiveBroadcastStream()` opens its own native
/// subscription per call, and the native side only keeps the latest one.
static final Stream<UploadCompletion> completions = _statusChannel
.receiveBroadcastStream()
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
} }