Noo design system rebuild #1
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user