fixed stale state after logout and replayed or lost share intents

This commit is contained in:
2026-09-24 21:43:21 +02:00
parent e4e2b1a4fb
commit 498d195138
32 changed files with 743 additions and 188 deletions
+8 -2
View File
@@ -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);
}
+171 -47
View File
@@ -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
}
}
}