fixed stale state after logout and replayed or lost share intents
This commit is contained in:
@@ -5,11 +5,17 @@ class PendingShare {
|
||||
final String? text;
|
||||
final DateTime receivedAt;
|
||||
|
||||
/// Paths as delivered by the platform, before the listener moved them into
|
||||
/// a per-share folder. Duplicate detection compares these, since the
|
||||
/// relocated [filePaths] differ for every delivery.
|
||||
final List<String> sourcePaths;
|
||||
|
||||
const PendingShare({
|
||||
required this.filePaths,
|
||||
required this.text,
|
||||
required this.receivedAt,
|
||||
});
|
||||
List<String>? sourcePaths,
|
||||
}) : sourcePaths = sourcePaths ?? filePaths;
|
||||
|
||||
bool get hasFiles => filePaths.isNotEmpty;
|
||||
bool get hasText => text != null && text!.isNotEmpty;
|
||||
@@ -20,5 +26,5 @@ class PendingShare {
|
||||
/// the same share can arrive twice on the media stream — receivedAt is
|
||||
/// deliberately ignored here so such duplicates compare equal.
|
||||
bool contentEquals(PendingShare other) =>
|
||||
text == other.text && listEquals(filePaths, other.filePaths);
|
||||
text == other.text && listEquals(sourcePaths, other.sourcePaths);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
|
||||
import 'pending_share.dart';
|
||||
@@ -14,56 +16,63 @@ class ShareIntentListener {
|
||||
|
||||
static final ValueNotifier<PendingShare?> pending = ValueNotifier(null);
|
||||
|
||||
StreamSubscription<List<SharedMediaFile>>? _streamSub;
|
||||
bool _initialized = false;
|
||||
static const _androidChannel = MethodChannel('eu.mhsl.marianum.share');
|
||||
|
||||
/// Reads the cold-start payload exactly once. Call from `main()` before
|
||||
/// `runApp` so the share is queued before the UI mounts.
|
||||
/// Per-share folders live below this directory name — on Android in the
|
||||
/// cache dir (created here), on iOS in the App Group container (created by
|
||||
/// the Share Extension).
|
||||
static const _shareDirName = 'share_intent';
|
||||
|
||||
bool _initialized = false;
|
||||
String? _cacheDir;
|
||||
|
||||
int _flowDepth = 0;
|
||||
PendingShare? _queued;
|
||||
PendingShare? _shown;
|
||||
|
||||
/// Subscribes to warm shares and reads the cold-start payload. Call from
|
||||
/// `main()` before `runApp`. The subscription stays for the process
|
||||
/// lifetime: the plugin drops shares that arrive while nobody listens
|
||||
/// (login screen, app remount), and [pending] buffers until the UI is ready.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
final initial = await ReceiveSharingIntent.instance.getInitialMedia();
|
||||
final share = _toPendingShare(initial);
|
||||
if (share != null) _publish(share);
|
||||
if (Platform.isAndroid) {
|
||||
_cacheDir = (await getTemporaryDirectory()).path;
|
||||
_sweepStaleAndroidShares();
|
||||
}
|
||||
ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||
_onItems,
|
||||
onError: (Object e) => debugPrint('ShareIntentListener stream: $e'),
|
||||
);
|
||||
_onItems(await ReceiveSharingIntent.instance.getInitialMedia());
|
||||
await ReceiveSharingIntent.instance.reset();
|
||||
if (Platform.isAndroid) {
|
||||
await _androidChannel.invokeMethod<void>('listenerReady');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ShareIntentListener.initialize failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to warm-share stream events. Safe to call multiple times.
|
||||
void attach() {
|
||||
_streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||
(items) {
|
||||
final share = _toPendingShare(items);
|
||||
if (share != null) _publish(share);
|
||||
},
|
||||
onError: (Object e) =>
|
||||
debugPrint('ShareIntentListener stream error: $e'),
|
||||
);
|
||||
/// Whether the share flow for [share] is already on screen. Re-running the
|
||||
/// navigation for it would pop its own page and thereby clear it.
|
||||
bool isShown(PendingShare share) => identical(share, _shown);
|
||||
|
||||
void markShown(PendingShare share) => _shown = share;
|
||||
|
||||
/// Marks a share flow step that must not be interrupted (upload, share API
|
||||
/// call). Shares arriving meanwhile are queued instead of replacing the
|
||||
/// current one, whose files are still in use. Pair with [endFlow].
|
||||
void beginFlow() => _flowDepth++;
|
||||
|
||||
void endFlow() {
|
||||
if (_flowDepth > 0) _flowDepth--;
|
||||
if (_flowDepth == 0) _drainQueue();
|
||||
}
|
||||
|
||||
/// The iOS Share Extension fires two `open(url)` requests per share, so the
|
||||
/// same payload can arrive twice in quick succession. Publishing the
|
||||
/// duplicate would re-trigger the share-flow navigation, pop the already
|
||||
/// open ShareTargetPage and thereby delete the temp files of the share that
|
||||
/// is still in flight — swallow it instead.
|
||||
void _publish(PendingShare share) {
|
||||
final current = pending.value;
|
||||
if (current != null && current.contentEquals(share)) return;
|
||||
pending.value = share;
|
||||
}
|
||||
|
||||
/// Cancels the warm-share subscription. The singleton survives, so a
|
||||
/// subsequent [attach] re-subscribes.
|
||||
void detach() {
|
||||
_streamSub?.cancel();
|
||||
_streamSub = null;
|
||||
}
|
||||
|
||||
/// Discards the current share and removes any temp files the plugin copied
|
||||
/// into the app cache. Idempotent.
|
||||
/// Discards the current share and deletes its temp files. Idempotent.
|
||||
///
|
||||
/// Pass [ifCurrent] from UI that owns a specific share (e.g. the
|
||||
/// ShareTargetPage pop handler): the call then only acts while that share
|
||||
@@ -73,18 +82,58 @@ class ShareIntentListener {
|
||||
void clear({PendingShare? ifCurrent}) {
|
||||
final current = pending.value;
|
||||
if (ifCurrent != null && !identical(current, ifCurrent)) return;
|
||||
_shown = null;
|
||||
pending.value = null;
|
||||
if (current != null) {
|
||||
for (final path in current.filePaths) {
|
||||
try {
|
||||
final f = File(path);
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {
|
||||
// best-effort cleanup; OS will reclaim cache eventually
|
||||
}
|
||||
if (current != null) _deleteFiles(current);
|
||||
unawaited(ReceiveSharingIntent.instance.reset());
|
||||
if (_flowDepth == 0) _drainQueue();
|
||||
}
|
||||
|
||||
/// Drops every share, including a queued one — for sign-out.
|
||||
void clearAll() {
|
||||
final queued = _queued;
|
||||
_queued = null;
|
||||
_flowDepth = 0;
|
||||
if (queued != null) _deleteFiles(queued);
|
||||
clear();
|
||||
}
|
||||
|
||||
void _onItems(List<SharedMediaFile> items) {
|
||||
final share = _toPendingShare(items);
|
||||
if (share == null) return;
|
||||
// The iOS Share Extension opens the app twice per share, so the same
|
||||
// payload can arrive again while it is still pending. Publishing it would
|
||||
// re-run the navigation and pop the share that is in flight.
|
||||
for (final live in [pending.value, _queued]) {
|
||||
if (live != null && live.contentEquals(share)) {
|
||||
_deleteFiles(share, keep: live.filePaths);
|
||||
return;
|
||||
}
|
||||
}
|
||||
unawaited(ReceiveSharingIntent.instance.reset());
|
||||
final claimed = _claim(share);
|
||||
if (_flowDepth > 0) {
|
||||
final replaced = _queued;
|
||||
_queued = claimed;
|
||||
if (replaced != null) _deleteFiles(replaced);
|
||||
return;
|
||||
}
|
||||
_replaceCurrent(claimed);
|
||||
}
|
||||
|
||||
void _drainQueue() {
|
||||
final queued = _queued;
|
||||
if (queued == null) return;
|
||||
_queued = null;
|
||||
_replaceCurrent(queued);
|
||||
}
|
||||
|
||||
// No flow is running, so nothing uses the previous share's files anymore;
|
||||
// its pages are popped when the new share is routed.
|
||||
void _replaceCurrent(PendingShare share) {
|
||||
final previous = pending.value;
|
||||
_shown = null;
|
||||
pending.value = share;
|
||||
if (previous != null) _deleteFiles(previous);
|
||||
}
|
||||
|
||||
PendingShare? _toPendingShare(List<SharedMediaFile> items) {
|
||||
@@ -96,7 +145,9 @@ class ShareIntentListener {
|
||||
case SharedMediaType.image:
|
||||
case SharedMediaType.video:
|
||||
case SharedMediaType.file:
|
||||
files.add(item.path);
|
||||
// A replayed share can point at temp copies deleted after the
|
||||
// original share completed.
|
||||
if (File(item.path).existsSync()) files.add(item.path);
|
||||
case SharedMediaType.text:
|
||||
case SharedMediaType.url:
|
||||
texts.add(item.path);
|
||||
@@ -109,4 +160,77 @@ class ShareIntentListener {
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The Android plugin copies shared content to `cacheDir/<display name>`,
|
||||
/// so a second share with an equally named file would overwrite the first
|
||||
/// one's copy. Move owned copies into a folder of their own. iOS shares
|
||||
/// already arrive in a per-share folder.
|
||||
PendingShare _claim(PendingShare share) {
|
||||
final cacheDir = _cacheDir;
|
||||
if (!Platform.isAndroid || cacheDir == null || !share.hasFiles) {
|
||||
return share;
|
||||
}
|
||||
final dir = Directory(
|
||||
'$cacheDir/$_shareDirName/${share.receivedAt.microsecondsSinceEpoch}',
|
||||
);
|
||||
final claimed = <String>[];
|
||||
for (final (i, path) in share.filePaths.indexed) {
|
||||
if (!_isOwned(path)) {
|
||||
claimed.add(path);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
dir.createSync(recursive: true);
|
||||
final name = path.split(Platform.pathSeparator).last;
|
||||
var target = '${dir.path}/$name';
|
||||
if (File(target).existsSync()) target = '${dir.path}/$i-$name';
|
||||
claimed.add(File(path).renameSync(target).path);
|
||||
} catch (_) {
|
||||
claimed.add(path);
|
||||
}
|
||||
}
|
||||
return PendingShare(
|
||||
filePaths: claimed,
|
||||
text: share.text,
|
||||
receivedAt: share.receivedAt,
|
||||
sourcePaths: share.sourcePaths,
|
||||
);
|
||||
}
|
||||
|
||||
/// Only temp copies are deleted: on Android the plugin hands out the real
|
||||
/// path for some providers (e.g. a file in Downloads), which must survive.
|
||||
bool _isOwned(String path) {
|
||||
if (Platform.isIOS) return true;
|
||||
final cacheDir = _cacheDir;
|
||||
return cacheDir != null && path.startsWith('$cacheDir/');
|
||||
}
|
||||
|
||||
void _deleteFiles(PendingShare share, {List<String> keep = const []}) {
|
||||
for (final path in share.filePaths) {
|
||||
if (keep.contains(path) || !_isOwned(path)) continue;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
final dir = file.parent;
|
||||
if (dir.parent.path.endsWith('/$_shareDirName') &&
|
||||
dir.listSync().isEmpty) {
|
||||
dir.deleteSync();
|
||||
}
|
||||
} catch (_) {
|
||||
// best-effort cleanup; OS will reclaim cache eventually
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shares whose flow never finished (process killed) leave their folder
|
||||
// behind; the pending one of this process is not claimed yet.
|
||||
void _sweepStaleAndroidShares() {
|
||||
try {
|
||||
final root = Directory('$_cacheDir/$_shareDirName');
|
||||
if (!root.existsSync()) return;
|
||||
root.deleteSync(recursive: true);
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user