From d008185f989fdffab758789eb95f333f16bfadb4 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:06:19 -0400 Subject: [PATCH] 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 --- .claude/context/server.md | 31 ++++++++++++ ios/Runner/Info.plist | 4 ++ lib/providers/offline_controller.dart | 7 ++- lib/services/download_service.dart | 5 +- lib/services/native_channel.dart | 55 +++++++++++++++++++++ lib/services/pick_intent_service.dart | 11 +++-- lib/services/share_intent_service.dart | 6 ++- lib/services/sync_service.dart | 36 +++++++++----- lib/services/upload_service.dart | 9 ++-- test/services/native_channel_test.dart | 67 ++++++++++++++++++++++++++ 10 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 test/services/native_channel_test.dart diff --git a/.claude/context/server.md b/.claude/context/server.md index 4080700..1ab5158 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -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 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. + +## 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("")`, 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//...` 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. diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 29e8c6b..89fef25 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,6 +53,10 @@ LaunchScreen UIMainStoryboardFile Main + NSFaceIDUsageDescription + Noo uses Face ID to unlock the app and to protect hidden files. + NSPhotoLibraryUsageDescription + Noo needs access to your photos and videos so you can choose which ones to upload to your server. UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/lib/providers/offline_controller.dart b/lib/providers/offline_controller.dart index 3939e7e..d744bb2 100644 --- a/lib/providers/offline_controller.dart +++ b/lib/providers/offline_controller.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import '../models/nextcloud_item.dart'; import '../services/sync_service.dart'; import 'files_controller.dart'; @@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier notifyListeners(); try { - _syncBasePath ??= (await getExternalStorageDirectory())?.path; + _syncBasePath ??= (await SyncService.baseDirectory())?.path; // A sync can delete the very folder being browsed (deleted on the // server) - step out to the nearest ancestor that still exists rather // than showing a phantom empty folder. @@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier Future localPathFor(NextcloudItem item) async { final accountId = session.activeAccountId; if (accountId == null) return null; - final base = await getExternalStorageDirectory(); + final base = await SyncService.baseDirectory(); if (base == null) return null; final relPath = item.path.startsWith('/') ? item.path.substring(1) @@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier String accountId, String folderPath, ) async { - final base = await getExternalStorageDirectory(); + final base = await SyncService.baseDirectory(); if (base == null) return []; final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', ''); final dir = Directory(p.join(base.path, 'sync', accountId, relFolder)); diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 146a70c..348517a 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -38,6 +38,9 @@ class DownloadService { .toList(), ); - await _channel.invokeMethod('startDownload', {...args, 'files': filesJson}); + await invokeOrExplain(_channel, 'startDownload', 'Downloading', { + ...args, + 'files': filesJson, + }); } } diff --git a/lib/services/native_channel.dart b/lib/services/native_channel.dart index ef12b69..b2de9f1 100644 --- a/lib/services/native_channel.dart +++ b/lib/services/native_channel.dart @@ -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 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 invokeIfAvailable( + MethodChannel channel, + String method, [ + Object? arguments, +]) async { + try { + return await channel.invokeMethod(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 invokeOrExplain( + MethodChannel channel, + String method, + String feature, [ + Object? arguments, +]) async { + try { + return await channel.invokeMethod(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 quietEvents(EventChannel channel, T Function(dynamic) convert) { + return channel + .receiveBroadcastStream() + .handleError((Object _) {}, test: (e) => e is MissingPluginException) + .map(convert); +} diff --git a/lib/services/pick_intent_service.dart b/lib/services/pick_intent_service.dart index 7781b5d..f9e4852 100644 --- a/lib/services/pick_intent_service.dart +++ b/lib/services/pick_intent_service.dart @@ -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 getPickRequest() async { - final result = await _methodChannel.invokeMethod>( + final result = await invokeIfAvailable>( + _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 get onNewPickRequest { - return _newPickChannel.receiveBroadcastStream().map( + return quietEvents( + _newPickChannel, (event) => PickRequest.fromMap(event as Map), ); } @@ -34,7 +37,7 @@ class PickIntentService { List localPaths, List 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 cancelPick() { - return _methodChannel.invokeMethod('cancelPick'); + return invokeIfAvailable(_methodChannel, 'cancelPick'); } } diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 85ec749..272d648 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -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> getInitialShare() async { - final result = await _methodChannel.invokeMethod>( + final result = await invokeIfAvailable>( + _methodChannel, 'getInitialShare', ); return (result ?? []) @@ -51,7 +53,7 @@ class ShareIntentService { /// Emits whenever another share arrives while the app is already running. static Stream> get onNewShare { - return _newShareChannel.receiveBroadcastStream().map((event) { + return quietEvents(_newShareChannel, (event) { return (event as List) .cast>() .map(SharedFileRef.fromMap) diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index aa870be..7c15213 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -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 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 getStatus(String? accountId) async { - final result = await _channel.invokeMethod>( + final result = await invokeIfAvailable>( + _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 statusStream = _statusChannel - .receiveBroadcastStream() - .map( - (event) => SyncStatusSnapshot.fromMap(event as Map), - ); + static final Stream statusStream = quietEvents( + _statusChannel, + (event) => SyncStatusSnapshot.fromMap(event as Map), + ); /// 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//...` 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 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 /// `/sync//...` 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) diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index ebda2c2..9e6c434 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -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 completions = _statusChannel - .receiveBroadcastStream() - .map((event) => UploadCompletion.fromMap(event as Map)); + static final Stream completions = quietEvents( + _statusChannel, + (event) => UploadCompletion.fromMap(event as Map), + ); } diff --git a/test/services/native_channel_test.dart b/test/services/native_channel_test.dart new file mode 100644 index 0000000..d4f3f6f --- /dev/null +++ b/test/services/native_channel_test.dart @@ -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(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(live, 'ping'), 'pong'); + }); + + test('invokeOrExplain names the feature the user asked for', () { + expect( + invokeOrExplain(channel, 'startUpload', 'Uploading'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + "Uploading isn't available on this platform yet.", + ), + ), + ); + }); + + test('quietEvents swallows the missing-implementation error', () async { + final errors = []; + final values = []; + final done = Completer(); + quietEvents( + 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.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)); + }); +}