iOS: degrade gracefully where the Android-only native services are missing
Share/pick/sync calls and streams no longer throw MissingPluginException at startup; upload/download/sync-now show 'X isn't available on this platform yet'. Offline/sync mirror dir no longer uses the Android-only external dir. Info.plist: Face ID and photo library usage strings. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
813c613249
commit
d008185f98
@@ -811,3 +811,34 @@ rather than relying on this async path throwing naturally.
|
|||||||
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||||
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||||
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||||
|
|
||||||
|
## Native services on iOS (in progress)
|
||||||
|
|
||||||
|
The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
|
||||||
|
`upload_service`, `download_service`, `sync_service`, plus their status
|
||||||
|
`EventChannel`s) are implemented in Kotlin only; iOS has no handler for any
|
||||||
|
of them yet, so every call there throws `MissingPluginException`.
|
||||||
|
`lib/services/native_channel.dart` makes that safe until each one is built
|
||||||
|
in Swift:
|
||||||
|
|
||||||
|
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
|
||||||
|
checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and
|
||||||
|
scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`,
|
||||||
|
`finishPick`/`cancelPick`).
|
||||||
|
- `invokeOrExplain` - throws `NativeServiceUnavailable("<Feature>")`, whose
|
||||||
|
message callers already show in a snackbar ("Uploading isn't available on
|
||||||
|
this platform yet."). For things the user just asked for: `startUpload`,
|
||||||
|
`startDownload`, `syncNow`, `resolveConflict`.
|
||||||
|
- `quietEvents` - wraps an `EventChannel` so the missing-implementation error
|
||||||
|
is dropped instead of surfacing as an unhandled stream error.
|
||||||
|
|
||||||
|
When a channel gets a real iOS implementation nothing changes on the Dart
|
||||||
|
side - the helpers only act on `MissingPluginException`.
|
||||||
|
|
||||||
|
`SyncService.baseDirectory()` is where the `sync/<accountId>/...` mirror
|
||||||
|
lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt`
|
||||||
|
writes), the app support directory elsewhere, because that call throws on
|
||||||
|
iOS. `OfflineController` and `localSyncedFilePath` both go through it.
|
||||||
|
Not built on iOS yet: Share Extension (needs an App Group), the picker
|
||||||
|
(File Provider), background uploads/downloads (`URLSession`), and sync
|
||||||
|
(`BGTaskScheduler`) - see the iOS handoff notes.
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Noo needs access to your photos and videos so you can choose which ones to upload to your server.</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
import 'files_controller.dart';
|
import 'files_controller.dart';
|
||||||
@@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
|
_syncBasePath ??= (await SyncService.baseDirectory())?.path;
|
||||||
// A sync can delete the very folder being browsed (deleted on the
|
// A sync can delete the very folder being browsed (deleted on the
|
||||||
// server) - step out to the nearest ancestor that still exists rather
|
// server) - step out to the nearest ancestor that still exists rather
|
||||||
// than showing a phantom empty folder.
|
// than showing a phantom empty folder.
|
||||||
@@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
Future<String?> localPathFor(NextcloudItem item) async {
|
Future<String?> localPathFor(NextcloudItem item) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return null;
|
if (accountId == null) return null;
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = item.path.startsWith('/')
|
final relPath = item.path.startsWith('/')
|
||||||
? item.path.substring(1)
|
? item.path.substring(1)
|
||||||
@@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
String accountId,
|
String accountId,
|
||||||
String folderPath,
|
String folderPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return [];
|
if (base == null) return [];
|
||||||
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
||||||
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class DownloadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
|
await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
|
||||||
|
...args,
|
||||||
|
'files': filesJson,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
|
||||||
/// The "get this account's auth header, or bail" guard every native-
|
/// The "get this account's auth header, or bail" guard every native-
|
||||||
@@ -21,3 +22,57 @@ Map<String, String> baseChannelArgs(SessionController session) {
|
|||||||
'authHeader': requireAuthHeader(session),
|
'authHeader': requireAuthHeader(session),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thrown by user-triggered native services (upload, download, sync now...)
|
||||||
|
/// on a platform whose native side isn't implemented yet - the message is
|
||||||
|
/// shown to the user as-is, e.g. in a snackbar.
|
||||||
|
class NativeServiceUnavailable implements Exception {
|
||||||
|
final String feature;
|
||||||
|
|
||||||
|
const NativeServiceUnavailable(this.feature);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "$feature isn't available on this platform yet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls [method], treating a missing native implementation (no
|
||||||
|
/// `MethodChannel` handler registered, as on iOS until each service is
|
||||||
|
/// built there) as "nothing to report": returns null instead of throwing
|
||||||
|
/// `MissingPluginException`. For cold-start checks, scheduling and other
|
||||||
|
/// calls nobody is waiting on.
|
||||||
|
Future<T?> invokeIfAvailable<T>(
|
||||||
|
MethodChannel channel,
|
||||||
|
String method, [
|
||||||
|
Object? arguments,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
return await channel.invokeMethod<T>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [invokeIfAvailable], but for an action the user just asked for:
|
||||||
|
/// throws [NativeServiceUnavailable] naming [feature] rather than silently
|
||||||
|
/// doing nothing.
|
||||||
|
Future<T?> invokeOrExplain<T>(
|
||||||
|
MethodChannel channel,
|
||||||
|
String method,
|
||||||
|
String feature, [
|
||||||
|
Object? arguments,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
return await channel.invokeMethod<T>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
throw NativeServiceUnavailable(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [channel]'s broadcast stream mapped through [convert], with the
|
||||||
|
/// "no native implementation" error dropped - the stream just never emits.
|
||||||
|
Stream<T> quietEvents<T>(EventChannel channel, T Function(dynamic) convert) {
|
||||||
|
return channel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.handleError((Object _) {}, test: (e) => e is MissingPluginException)
|
||||||
|
.map(convert);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../models/pick_request.dart';
|
import '../models/pick_request.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
||||||
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
||||||
@@ -14,7 +15,8 @@ class PickIntentService {
|
|||||||
/// Non-null if the app was launched cold as another app's file/photo
|
/// Non-null if the app was launched cold as another app's file/photo
|
||||||
/// picker.
|
/// picker.
|
||||||
static Future<PickRequest?> getPickRequest() async {
|
static Future<PickRequest?> getPickRequest() async {
|
||||||
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getPickRequest',
|
'getPickRequest',
|
||||||
);
|
);
|
||||||
return result == null ? null : PickRequest.fromMap(result);
|
return result == null ? null : PickRequest.fromMap(result);
|
||||||
@@ -23,7 +25,8 @@ class PickIntentService {
|
|||||||
/// Emits whenever the app is asked to act as a picker while already
|
/// Emits whenever the app is asked to act as a picker while already
|
||||||
/// running.
|
/// running.
|
||||||
static Stream<PickRequest> get onNewPickRequest {
|
static Stream<PickRequest> get onNewPickRequest {
|
||||||
return _newPickChannel.receiveBroadcastStream().map(
|
return quietEvents(
|
||||||
|
_newPickChannel,
|
||||||
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ class PickIntentService {
|
|||||||
List<String> localPaths,
|
List<String> localPaths,
|
||||||
List<String> mimeTypes,
|
List<String> mimeTypes,
|
||||||
) {
|
) {
|
||||||
return _methodChannel.invokeMethod('finishPick', {
|
return invokeIfAvailable(_methodChannel, 'finishPick', {
|
||||||
'paths': localPaths,
|
'paths': localPaths,
|
||||||
'mimeTypes': mimeTypes,
|
'mimeTypes': mimeTypes,
|
||||||
});
|
});
|
||||||
@@ -42,6 +45,6 @@ class PickIntentService {
|
|||||||
|
|
||||||
/// Backs out of picking mode with no result, closing the picker.
|
/// Backs out of picking mode with no result, closing the picker.
|
||||||
static Future<void> cancelPick() {
|
static Future<void> cancelPick() {
|
||||||
return _methodChannel.invokeMethod('cancelPick');
|
return invokeIfAvailable(_methodChannel, 'cancelPick');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
||||||
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
||||||
@@ -40,7 +41,8 @@ class ShareIntentService {
|
|||||||
/// Whatever was shared to launch the app cold (empty if it was launched
|
/// Whatever was shared to launch the app cold (empty if it was launched
|
||||||
/// normally, not via a share).
|
/// normally, not via a share).
|
||||||
static Future<List<SharedFileRef>> getInitialShare() async {
|
static Future<List<SharedFileRef>> getInitialShare() async {
|
||||||
final result = await _methodChannel.invokeMethod<List<dynamic>>(
|
final result = await invokeIfAvailable<List<dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getInitialShare',
|
'getInitialShare',
|
||||||
);
|
);
|
||||||
return (result ?? [])
|
return (result ?? [])
|
||||||
@@ -51,7 +53,7 @@ class ShareIntentService {
|
|||||||
|
|
||||||
/// Emits whenever another share arrives while the app is already running.
|
/// Emits whenever another share arrives while the app is already running.
|
||||||
static Stream<List<SharedFileRef>> get onNewShare {
|
static Stream<List<SharedFileRef>> get onNewShare {
|
||||||
return _newShareChannel.receiveBroadcastStream().map((event) {
|
return quietEvents(_newShareChannel, (event) {
|
||||||
return (event as List<dynamic>)
|
return (event as List<dynamic>)
|
||||||
.cast<Map<dynamic, dynamic>>()
|
.cast<Map<dynamic, dynamic>>()
|
||||||
.map(SharedFileRef.fromMap)
|
.map(SharedFileRef.fromMap)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../models/saved_account.dart';
|
|||||||
import '../providers/files_controller.dart';
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
import '../providers/sync_status_controller.dart';
|
import '../providers/sync_status_controller.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
||||||
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
||||||
@@ -110,7 +111,7 @@ class SyncService {
|
|||||||
await cancelAccount(account.id);
|
await cancelAccount(account.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _channel.invokeMethod('reschedule', {
|
await invokeIfAvailable(_channel, 'reschedule', {
|
||||||
'accountId': account.id,
|
'accountId': account.id,
|
||||||
'serverUrl': account.serverUrl,
|
'serverUrl': account.serverUrl,
|
||||||
'username': account.username,
|
'username': account.username,
|
||||||
@@ -132,7 +133,7 @@ class SyncService {
|
|||||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||||
/// nothing left to sync).
|
/// nothing left to sync).
|
||||||
static Future<void> cancelAccount(String accountId) async {
|
static Future<void> cancelAccount(String accountId) async {
|
||||||
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
@@ -158,7 +159,7 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
||||||
await _channel.invokeMethod('syncNow', {
|
await invokeOrExplain(_channel, 'syncNow', 'Syncing', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -178,7 +179,8 @@ class SyncService {
|
|||||||
/// app start - the native bus only knows an account once a sync pass has
|
/// app start - the native bus only knows an account once a sync pass has
|
||||||
/// run in this process.
|
/// run in this process.
|
||||||
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_channel,
|
||||||
'getSyncStatus',
|
'getSyncStatus',
|
||||||
{'accountId': accountId},
|
{'accountId': accountId},
|
||||||
);
|
);
|
||||||
@@ -196,11 +198,10 @@ class SyncService {
|
|||||||
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
||||||
/// was restarted. This is a broadcast stream, so any number of Dart
|
/// was restarted. This is a broadcast stream, so any number of Dart
|
||||||
/// listeners share the one native subscription.
|
/// listeners share the one native subscription.
|
||||||
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
|
static final Stream<SyncStatusSnapshot> statusStream = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map(
|
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||||
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
||||||
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
||||||
@@ -213,7 +214,7 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
final authHeader = session.service?.authHeaders['Authorization'];
|
||||||
if (authHeader == null) throw Exception('Not logged in.');
|
if (authHeader == null) throw Exception('Not logged in.');
|
||||||
await _channel.invokeMethod('resolveConflict', {
|
await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', {
|
||||||
'accountId': conflict.accountId,
|
'accountId': conflict.accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -236,12 +237,23 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return;
|
if (accountId == null) return;
|
||||||
await _channel.invokeMethod('removeLocalSync', {
|
await invokeIfAvailable(_channel, 'removeLocalSync', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'path': path,
|
'path': path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the `sync/<accountId>/...` mirror lives: Android's app-specific
|
||||||
|
/// external files dir (what `SyncEngine.kt` writes to); everywhere else -
|
||||||
|
/// iOS has no external storage, `getExternalStorageDirectory` throws
|
||||||
|
/// there - the app's private support directory.
|
||||||
|
static Future<Directory?> baseDirectory() async {
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
return getExternalStorageDirectory();
|
||||||
|
}
|
||||||
|
return getApplicationSupportDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
/// The deterministic local mirror path for [remoteItemPath] under
|
/// The deterministic local mirror path for [remoteItemPath] under
|
||||||
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
||||||
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
||||||
@@ -251,7 +263,7 @@ class SyncService {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String remoteItemPath,
|
String remoteItemPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = remoteItemPath.startsWith('/')
|
final relPath = remoteItemPath.startsWith('/')
|
||||||
? remoteItemPath.substring(1)
|
? remoteItemPath.substring(1)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class UploadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startUpload', {
|
await invokeOrExplain(_channel, 'startUpload', 'Uploading', {
|
||||||
...args,
|
...args,
|
||||||
'files': filesJson,
|
'files': filesJson,
|
||||||
'remoteFolder': files.currentFolderPath,
|
'remoteFolder': files.currentFolderPath,
|
||||||
@@ -70,7 +70,8 @@ class UploadService {
|
|||||||
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
||||||
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
||||||
/// subscription per call, and the native side only keeps the latest one.
|
/// subscription per call, and the native side only keeps the latest one.
|
||||||
static final Stream<UploadCompletion> completions = _statusChannel
|
static final Stream<UploadCompletion> completions = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
|
(event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/services/native_channel.dart';
|
||||||
|
import 'package:noo/services/share_intent_service.dart';
|
||||||
|
|
||||||
|
/// iOS has no handler for the `dev.ayushya.noo/*` channels yet, so these use
|
||||||
|
/// channels with nothing registered - the same `MissingPluginException` the
|
||||||
|
/// simulator showed at startup.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = MethodChannel('test/no_native_side');
|
||||||
|
const events = EventChannel('test/no_native_side/events');
|
||||||
|
|
||||||
|
test(
|
||||||
|
'invokeIfAvailable returns null when nothing implements the channel',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
invokeIfAvailable<String>(channel, 'anything'),
|
||||||
|
completion(isNull),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('invokeIfAvailable still returns a real result', () async {
|
||||||
|
const live = MethodChannel('test/live');
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(live, (call) async => 'pong');
|
||||||
|
addTearDown(
|
||||||
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(live, null),
|
||||||
|
);
|
||||||
|
expect(await invokeIfAvailable<String>(live, 'ping'), 'pong');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invokeOrExplain names the feature the user asked for', () {
|
||||||
|
expect(
|
||||||
|
invokeOrExplain(channel, 'startUpload', 'Uploading'),
|
||||||
|
throwsA(
|
||||||
|
isA<NativeServiceUnavailable>().having(
|
||||||
|
(e) => e.toString(),
|
||||||
|
'message',
|
||||||
|
"Uploading isn't available on this platform yet.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quietEvents swallows the missing-implementation error', () async {
|
||||||
|
final errors = <Object>[];
|
||||||
|
final values = <int>[];
|
||||||
|
final done = Completer<void>();
|
||||||
|
quietEvents<int>(
|
||||||
|
events,
|
||||||
|
(e) => e as int,
|
||||||
|
).listen(values.add, onError: errors.add, onDone: done.complete);
|
||||||
|
// Give the (failing) native `listen` call time to come back.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(errors, isEmpty);
|
||||||
|
expect(values, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ShareIntentService has nothing to report without a native side', () {
|
||||||
|
expect(ShareIntentService.getInitialShare(), completion(isEmpty));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user