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:
Ayushya Amitabh
2026-10-06 19:06:19 -04:00
co-authored by Claude Sonnet 5.5
parent 813c613249
commit d008185f98
10 changed files with 204 additions and 27 deletions
+4 -1
View File
@@ -38,6 +38,9 @@ class DownloadService {
.toList(),
);
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
...args,
'files': filesJson,
});
}
}
+55
View File
@@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import '../providers/session_controller.dart';
/// 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),
};
}
/// 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);
}
+7 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/services.dart';
import '../models/pick_request.dart';
import 'native_channel.dart';
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
/// (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
/// picker.
static Future<PickRequest?> getPickRequest() async {
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
_methodChannel,
'getPickRequest',
);
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
/// running.
static Stream<PickRequest> get onNewPickRequest {
return _newPickChannel.receiveBroadcastStream().map(
return quietEvents(
_newPickChannel,
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
);
}
@@ -34,7 +37,7 @@ class PickIntentService {
List<String> localPaths,
List<String> mimeTypes,
) {
return _methodChannel.invokeMethod('finishPick', {
return invokeIfAvailable(_methodChannel, 'finishPick', {
'paths': localPaths,
'mimeTypes': mimeTypes,
});
@@ -42,6 +45,6 @@ class PickIntentService {
/// Backs out of picking mode with no result, closing the picker.
static Future<void> cancelPick() {
return _methodChannel.invokeMethod('cancelPick');
return invokeIfAvailable(_methodChannel, 'cancelPick');
}
}
+4 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/services.dart';
import 'native_channel.dart';
/// 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
@@ -40,7 +41,8 @@ class ShareIntentService {
/// Whatever was shared to launch the app cold (empty if it was launched
/// normally, not via a share).
static Future<List<SharedFileRef>> getInitialShare() async {
final result = await _methodChannel.invokeMethod<List<dynamic>>(
final result = await invokeIfAvailable<List<dynamic>>(
_methodChannel,
'getInitialShare',
);
return (result ?? [])
@@ -51,7 +53,7 @@ class ShareIntentService {
/// Emits whenever another share arrives while the app is already running.
static Stream<List<SharedFileRef>> get onNewShare {
return _newShareChannel.receiveBroadcastStream().map((event) {
return quietEvents(_newShareChannel, (event) {
return (event as List<dynamic>)
.cast<Map<dynamic, dynamic>>()
.map(SharedFileRef.fromMap)
+24 -12
View File
@@ -10,6 +10,7 @@ import '../models/saved_account.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
import 'native_channel.dart';
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
@@ -110,7 +111,7 @@ class SyncService {
await cancelAccount(account.id);
continue;
}
await _channel.invokeMethod('reschedule', {
await invokeIfAvailable(_channel, 'reschedule', {
'accountId': account.id,
'serverUrl': account.serverUrl,
'username': account.username,
@@ -132,7 +133,7 @@ class SyncService {
/// Stops [accountId]'s periodic job (the account was removed, or has
/// nothing left to sync).
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
@@ -158,7 +159,7 @@ class SyncService {
}
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
await _channel.invokeMethod('syncNow', {
await invokeOrExplain(_channel, 'syncNow', 'Syncing', {
'accountId': accountId,
'serverUrl': session.serverUrl,
'username': session.username,
@@ -178,7 +179,8 @@ class SyncService {
/// app start - the native bus only knows an account once a sync pass has
/// run in this process.
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
_channel,
'getSyncStatus',
{'accountId': accountId},
);
@@ -196,11 +198,10 @@ class SyncService {
/// (`SyncStatusController`), so sync badges stopped updating until the app
/// was restarted. This is a broadcast stream, so any number of Dart
/// listeners share the one native subscription.
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
.receiveBroadcastStream()
.map(
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
static final Stream<SyncStatusSnapshot> statusStream = quietEvents(
_statusChannel,
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
/// In-app conflict resolution (the header's "Keep local"/"Use server"
/// buttons) - shares the exact same native `ConflictResolveWorker`
@@ -213,7 +214,7 @@ class SyncService {
) async {
final authHeader = session.service?.authHeaders['Authorization'];
if (authHeader == null) throw Exception('Not logged in.');
await _channel.invokeMethod('resolveConflict', {
await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', {
'accountId': conflict.accountId,
'serverUrl': session.serverUrl,
'username': session.username,
@@ -236,12 +237,23 @@ class SyncService {
) async {
final accountId = session.activeAccountId;
if (accountId == null) return;
await _channel.invokeMethod('removeLocalSync', {
await invokeIfAvailable(_channel, 'removeLocalSync', {
'accountId': accountId,
'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
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
@@ -251,7 +263,7 @@ class SyncService {
String accountId,
String remoteItemPath,
) async {
final base = await getExternalStorageDirectory();
final base = await baseDirectory();
if (base == null) return null;
final relPath = remoteItemPath.startsWith('/')
? remoteItemPath.substring(1)
+5 -4
View File
@@ -56,7 +56,7 @@ class UploadService {
.toList(),
);
await _channel.invokeMethod('startUpload', {
await invokeOrExplain(_channel, 'startUpload', 'Uploading', {
...args,
'files': filesJson,
'remoteFolder': files.currentFolderPath,
@@ -70,7 +70,8 @@ class UploadService {
/// 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>));
static final Stream<UploadCompletion> completions = quietEvents(
_statusChannel,
(event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>),
);
}