diff --git a/.claude/context/server.md b/.claude/context/server.md index 8fb0f78..555dd17 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -206,13 +206,26 @@ rather than needing a rewrite for multi-account support. Dio from a separate process lifecycle - `UploadService.startUpload` passes everything the Kotlin side needs (the pre-built `Authorization` header from `NextcloudService.authHeaders`, not the raw password) as Intent - extras, a one-way handoff with no channel back to Dart afterward. Keep - the two upload implementations in sync manually if upload semantics - change. Progress/cancellation is entirely notification-driven (one - ongoing, updatable notification for the whole batch; its Cancel action - re-delivers an Intent to the same running service instance, which an - `AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to - the Dart UI, by design, since the app may not even be running. + extras, a one-way handoff for the start of the upload - the Kotlin side + never asks Dart anything mid-upload, since the app may not even be + running by then. Keep the two upload implementations in sync manually if + upload semantics change. Progress/cancellation is entirely notification- + driven (one ongoing, updatable notification for the whole batch; its + Cancel action re-delivers an Intent to the same running service instance, + 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) diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt index cd51686..5acca1a 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt @@ -56,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() { private val newPickChannelName = "dev.ayushya.noo/pick_intent/new" private val syncServiceChannelName = "dev.ayushya.noo/sync_service" private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status" + private val uploadStatusChannelName = "dev.ayushya.noo/upload_service/status" private val notificationPermissionRequestCode = 4202 // Lazy, not a field initializer - `packageName` reads through the // 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 newPickSink: EventChannel.EventSink? = null private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null + private var uploadCompletedListener: ((UploadEventBus.Completed) -> Unit)? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -157,6 +159,29 @@ class MainActivity : FlutterFragmentActivity() { 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 diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt index 569aebd..5e6b682 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt @@ -185,6 +185,13 @@ class ShareUploadService : Service() { else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed" } 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) { stopForeground(STOP_FOREGROUND_DETACH) diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt b/android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt new file mode 100644 index 0000000..34835ff --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt @@ -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) } + } +} diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index af0013d..29fbe55 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/nextcloud_item.dart'; +import '../services/upload_service.dart'; import 'folder_browser.dart'; import 'session_controller.dart'; @@ -107,6 +108,22 @@ class FilesController extends ChangeNotifier session.addAccountClearedListener(_onAccountCleared); session.addAccountActivatedListener(_onAccountActivated); 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? _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 @@ -711,6 +728,7 @@ class FilesController extends ChangeNotifier void dispose() { WidgetsBinding.instance.removeObserver(this); _cacheRefreshTimer?.cancel(); + _uploadSub?.cancel(); super.dispose(); } } diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index f047ce6..ebda2c2 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -5,6 +5,27 @@ import '../providers/session_controller.dart'; import 'native_channel.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 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 /// foreground service, which does the actual prepare+upload entirely on its /// own from there - including surviving the app being closed - with a @@ -14,11 +35,15 @@ import 'share_intent_service.dart'; /// real Android Service does. class UploadService { 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. /// Fire-and-forget from Dart's perspective - once this returns, the /// 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 startUpload( SessionController session, FilesController files, @@ -37,4 +62,15 @@ class UploadService { '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 completions = _statusChannel + .receiveBroadcastStream() + .map((event) => UploadCompletion.fromMap(event as Map)); }