mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 01:37:03 +00:00
1696 lines
57 KiB
Dart
1696 lines
57 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
|
|
import '../../features/auth/providers/unified_auth_providers.dart';
|
|
import '../../features/chat/providers/chat_providers.dart';
|
|
import '../../features/chat/services/file_attachment_service.dart';
|
|
import '../../features/direct_connections/direct_connections.dart';
|
|
import '../../features/hermes/models/hermes_model.dart';
|
|
import '../../core/providers/app_providers.dart';
|
|
import 'media_upload_controller.dart';
|
|
|
|
import 'package:path/path.dart' as path;
|
|
|
|
import 'navigation_service.dart';
|
|
import 'share_staging_cleanup.dart';
|
|
import '../utils/debug_logger.dart';
|
|
// Server chat creation/title generation occur on first send via chat providers
|
|
|
|
part 'share_receiver_service.g.dart';
|
|
|
|
const int _maxSharedAttachmentCount = 6;
|
|
const int _maxSharedImageAttachmentSizeMB = 20;
|
|
const int _nativeShareImportMaxPollAttempts = 240;
|
|
const String _nativeShareImportTimedOutMessage =
|
|
'Could not finish importing shared attachments. Please try sharing again.';
|
|
const _androidShareTextChannel = MethodChannel('conduit/share_receiver_text');
|
|
|
|
enum SharedPayloadProcessResult { processed, consumed, retry }
|
|
|
|
/// Process-local terminal fence for durable native share records.
|
|
///
|
|
/// Once composer state owns a payload, a transient native acknowledgement
|
|
/// failure must retry only the acknowledgement—not attach the same files or
|
|
/// text again. The native record remains durable across a process restart,
|
|
/// where processing can safely be recovered from scratch.
|
|
@visibleForTesting
|
|
final class NativeShareProcessingFence {
|
|
final Set<String> _terminalIds = <String>{};
|
|
|
|
bool shouldProcess(SharedPayload payload) {
|
|
final id = payload.id;
|
|
return id == null || !_terminalIds.contains(id);
|
|
}
|
|
|
|
void markTerminal(SharedPayload payload) {
|
|
final id = payload.id;
|
|
if (id != null) _terminalIds.add(id);
|
|
}
|
|
|
|
void release(String id) => _terminalIds.remove(id);
|
|
}
|
|
|
|
typedef SharedIncomingFileStager =
|
|
Future<IncomingSharedFileStageResult> Function(String filePath);
|
|
typedef SharedStagedFileRollback =
|
|
Future<ShareStagingFileCleanupResult> Function(String filePath);
|
|
typedef LegacyPluginSourceRootResolver = Future<Directory?> Function();
|
|
|
|
final class _PreparedSharedAttachment {
|
|
const _PreparedSharedAttachment({
|
|
required this.attachment,
|
|
required this.fileSize,
|
|
});
|
|
|
|
final LocalAttachment attachment;
|
|
final int fileSize;
|
|
}
|
|
|
|
final class _PreparedSharedAttachmentBatch {
|
|
_PreparedSharedAttachmentBatch({
|
|
required this.prepared,
|
|
required List<String> copiedStagingPaths,
|
|
required List<IncomingSharedSourceDeletionLease> copiedSourceLeases,
|
|
required Future<ShareStagingFileCleanupResult> Function(String filePath)
|
|
rollbackStagedFile,
|
|
required Future<bool> Function(
|
|
IncomingSharedSourceDeletionLease sourceLease,
|
|
)
|
|
cleanupCopiedSource,
|
|
}) : _copiedStagingPaths = copiedStagingPaths,
|
|
_copiedSourceLeases = copiedSourceLeases,
|
|
_rollbackStagedFile = rollbackStagedFile,
|
|
_cleanupCopiedSource = cleanupCopiedSource;
|
|
|
|
final List<_PreparedSharedAttachment> prepared;
|
|
final List<String> _copiedStagingPaths;
|
|
final List<IncomingSharedSourceDeletionLease> _copiedSourceLeases;
|
|
final Future<ShareStagingFileCleanupResult> Function(String filePath)
|
|
_rollbackStagedFile;
|
|
final Future<bool> Function(IncomingSharedSourceDeletionLease sourceLease)
|
|
_cleanupCopiedSource;
|
|
bool _committed = false;
|
|
|
|
bool get isCommitted => _committed;
|
|
bool get hasRollbackArtifacts => _copiedStagingPaths.isNotEmpty;
|
|
|
|
List<LocalAttachment> get attachments =>
|
|
prepared.map((entry) => entry.attachment).toList(growable: false);
|
|
|
|
/// Transfers one newly-created staging path to a durable upload owner.
|
|
/// It must no longer participate in a later whole-batch rollback.
|
|
void transferStagedPath(String filePath) {
|
|
_copiedStagingPaths.remove(filePath);
|
|
}
|
|
|
|
Future<void> commit() async {
|
|
if (_committed) return;
|
|
// Publish ownership before deleting any retryable plugin source.
|
|
_committed = true;
|
|
for (final sourceLease in _copiedSourceLeases) {
|
|
try {
|
|
final removed = await _cleanupCopiedSource(sourceLease);
|
|
if (!removed) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: copied source cleanup deferred',
|
|
scope: 'share/receiver',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// Keeping a duplicate source is safer than retrying a payload whose
|
|
// staged copy is already visible in the composer.
|
|
DebugLogger.log(
|
|
'ShareReceiver: copied source cleanup failed',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> rollback() async {
|
|
if (_committed) return true;
|
|
// Forget each path only after its unlink is confirmed. A later retry then
|
|
// targets only retained artifacts instead of misclassifying an already
|
|
// removed path as a fresh rollback failure.
|
|
for (final stagedPath in _copiedStagingPaths.reversed.toList()) {
|
|
var removed = false;
|
|
try {
|
|
final result = await _rollbackStagedFile(stagedPath);
|
|
removed = result == ShareStagingFileCleanupResult.removed;
|
|
if (result == ShareStagingFileCleanupResult.notOwned) {
|
|
// This batch created the UUID path. If it vanished before rollback,
|
|
// the rollback goal is already satisfied; an extant but no-longer-
|
|
// owned path must remain visible for diagnostics.
|
|
removed =
|
|
await FileSystemEntity.type(stagedPath, followLinks: false) ==
|
|
FileSystemEntityType.notFound;
|
|
}
|
|
if (!removed) {
|
|
DebugLogger.warning(
|
|
'shared-payload-staging-rollback-deferred',
|
|
scope: 'share/receiver',
|
|
data: {'result': result.name},
|
|
);
|
|
}
|
|
} catch (error) {
|
|
DebugLogger.warning(
|
|
'shared-payload-staging-rollback-failed',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
if (removed) _copiedStagingPaths.remove(stagedPath);
|
|
}
|
|
return _copiedStagingPaths.isEmpty;
|
|
}
|
|
}
|
|
|
|
typedef _SharedAttachmentPreparation = ({
|
|
bool shouldRetry,
|
|
_PreparedSharedAttachmentBatch batch,
|
|
});
|
|
|
|
Future<bool> _rollbackSharedAttachmentBatchForRetry(
|
|
_PreparedSharedAttachmentBatch batch,
|
|
) async {
|
|
// A transient unlink failure must not be silently discarded: retry once in
|
|
// a fresh event-loop turn, then surface the retained copy for diagnostics.
|
|
for (var attempt = 0; attempt < 2; attempt++) {
|
|
if (await batch.rollback()) return true;
|
|
if (attempt == 0) await Future<void>.delayed(Duration.zero);
|
|
}
|
|
DebugLogger.warning(
|
|
'shared-payload-staging-rollback-incomplete',
|
|
scope: 'share/receiver',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
final class _SharedFileCandidate {
|
|
const _SharedFileCandidate({
|
|
required this.filePath,
|
|
required this.displayName,
|
|
required this.fileSize,
|
|
this.sourceDeletionLease,
|
|
});
|
|
|
|
final String filePath;
|
|
final String displayName;
|
|
final int fileSize;
|
|
final IncomingSharedSourceDeletionLease? sourceDeletionLease;
|
|
}
|
|
|
|
/// Lightweight payload for a share event
|
|
class SharedPayload {
|
|
final String? id;
|
|
final String? text;
|
|
final List<String> filePaths;
|
|
final bool isLegacyPluginPayload;
|
|
const SharedPayload({
|
|
this.id,
|
|
this.text,
|
|
this.filePaths = const [],
|
|
this.isLegacyPluginPayload = false,
|
|
});
|
|
|
|
factory SharedPayload.fromMap(dynamic value) {
|
|
if (value is! Map) return const SharedPayload();
|
|
|
|
final rawId = value['id'];
|
|
final rawText = value['text'];
|
|
final trimmedId = rawId is String ? rawId.trim() : '';
|
|
final id = trimmedId.isNotEmpty ? trimmedId : null;
|
|
final text = rawText is String ? rawText : null;
|
|
final rawFilePaths = value['filePaths'];
|
|
final filePaths = rawFilePaths is List
|
|
? rawFilePaths
|
|
.whereType<String>()
|
|
.where((path) => path.isNotEmpty)
|
|
.toList()
|
|
: const <String>[];
|
|
|
|
return SharedPayload(id: id, text: text, filePaths: filePaths);
|
|
}
|
|
|
|
factory SharedPayload.fromSharedFiles(
|
|
List<SharedMediaFile> files, {
|
|
String? extraText,
|
|
}) {
|
|
final textParts = <String>[];
|
|
final seenText = <String>{};
|
|
final filePaths = <String>[];
|
|
final seenFilePaths = <String>{};
|
|
|
|
void addText(String? value) {
|
|
final trimmed = value?.trim();
|
|
if (trimmed == null || trimmed.isEmpty || !seenText.add(trimmed)) {
|
|
return;
|
|
}
|
|
textParts.add(trimmed);
|
|
}
|
|
|
|
void addFilePath(String? value) {
|
|
final normalized = _normalizeSharedFilePath(value);
|
|
if (normalized == null || !seenFilePaths.add(normalized)) {
|
|
return;
|
|
}
|
|
filePaths.add(normalized);
|
|
}
|
|
|
|
void deleteIgnoredSidecar(String? value, String? mainPath) {
|
|
final normalized = _normalizeSharedFilePath(value);
|
|
if (normalized == null || normalized == mainPath) {
|
|
return;
|
|
}
|
|
unawaited(deleteIgnoredShareSidecarFile(normalized));
|
|
}
|
|
|
|
addText(extraText);
|
|
for (final file in files) {
|
|
addText(file.message);
|
|
final mainPath = _normalizeSharedFilePath(file.path);
|
|
deleteIgnoredSidecar(file.thumbnail, mainPath);
|
|
switch (_sharedFileKind(file)) {
|
|
case _SharedFileKind.text:
|
|
addText(file.path);
|
|
break;
|
|
case _SharedFileKind.file:
|
|
addFilePath(file.path);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return SharedPayload(
|
|
text: textParts.isEmpty ? null : textParts.join('\n'),
|
|
filePaths: filePaths,
|
|
isLegacyPluginPayload: true,
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toMap() => {
|
|
if (id != null) 'id': id,
|
|
if (text != null) 'text': text,
|
|
'filePaths': filePaths,
|
|
};
|
|
|
|
bool get hasAnything =>
|
|
(text != null && text!.trim().isNotEmpty) || filePaths.isNotEmpty;
|
|
}
|
|
|
|
/// Retains ownership of staging copies whose rollback could not yet finish.
|
|
///
|
|
/// Native payload IDs survive reconstruction, while plugin payloads are held
|
|
/// as the same object by [pendingSharedPayloadProvider]. Keeping both key forms
|
|
/// makes the next processing attempt drain the old batch before it is allowed
|
|
/// to create another set of staging copies.
|
|
final class _DeferredSharedRollbackRegistry {
|
|
final Map<String, _PreparedSharedAttachmentBatch> _byPayloadId =
|
|
<String, _PreparedSharedAttachmentBatch>{};
|
|
final HashMap<SharedPayload, _PreparedSharedAttachmentBatch> _byIdentity =
|
|
HashMap<SharedPayload, _PreparedSharedAttachmentBatch>.identity();
|
|
|
|
_PreparedSharedAttachmentBatch? batchFor(SharedPayload payload) {
|
|
final id = payload.id;
|
|
return id == null ? _byIdentity[payload] : _byPayloadId[id];
|
|
}
|
|
|
|
void retain(SharedPayload payload, _PreparedSharedAttachmentBatch batch) {
|
|
final id = payload.id;
|
|
if (id == null) {
|
|
_byIdentity[payload] = batch;
|
|
} else {
|
|
_byPayloadId[id] = batch;
|
|
}
|
|
}
|
|
|
|
void release(SharedPayload payload, _PreparedSharedAttachmentBatch batch) {
|
|
final id = payload.id;
|
|
if (id == null) {
|
|
if (identical(_byIdentity[payload], batch)) {
|
|
_byIdentity.remove(payload);
|
|
}
|
|
} else if (identical(_byPayloadId[id], batch)) {
|
|
_byPayloadId.remove(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
_DeferredSharedRollbackRegistry _deferredSharedRollbackRegistry(Ref ref) =>
|
|
_DeferredSharedRollbackRegistry();
|
|
|
|
/// Process-local bridge between durable Drift receipts and the native ack.
|
|
///
|
|
/// After a restart the native payload is still present until ack, so processing
|
|
/// reconstructs the same identity-derived keys (payload id + item ordinal)
|
|
/// before acknowledgement.
|
|
final class _NativeShareReceiptRegistry {
|
|
final Map<String, Set<String>> _byPayloadId = <String, Set<String>>{};
|
|
|
|
void retain(String payloadId, Iterable<String> receiptKeys) {
|
|
_byPayloadId[payloadId] = receiptKeys.toSet();
|
|
}
|
|
|
|
Set<String> keysFor(String payloadId) =>
|
|
Set<String>.unmodifiable(_byPayloadId[payloadId] ?? const <String>{});
|
|
|
|
void release(String payloadId) => _byPayloadId.remove(payloadId);
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
_NativeShareReceiptRegistry _nativeShareReceiptRegistry(Ref ref) =>
|
|
_NativeShareReceiptRegistry();
|
|
|
|
@visibleForTesting
|
|
Future<SharedPayload?> peekPendingNativeSharePayloadForTest(
|
|
MethodChannel channel,
|
|
) async {
|
|
final raw = await channel.invokeMethod<Object?>(
|
|
'takePendingShareImportPayload',
|
|
);
|
|
final payload = SharedPayload.fromMap(raw);
|
|
// Native records are durable and can only be consumed by exact-ID ack. A
|
|
// content-bearing record without an ID is malformed and must never reach
|
|
// composer processing (the native implementation discards it on peek).
|
|
return payload.hasAnything && payload.id != null ? payload : null;
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<bool> ackPendingNativeSharePayloadForTest(
|
|
MethodChannel channel,
|
|
String id,
|
|
) async {
|
|
return await channel.invokeMethod<bool>('ackPendingShareImportPayload', {
|
|
'id': id,
|
|
}) ??
|
|
false;
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<bool> acknowledgeNativeSharePayloadAfterProcessingForTest({
|
|
required SharedPayloadProcessResult result,
|
|
required SharedPayload payload,
|
|
required Future<bool> Function(String id) acknowledge,
|
|
}) async {
|
|
final id = payload.id;
|
|
if (result == SharedPayloadProcessResult.retry || id == null) return false;
|
|
return acknowledge(id);
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<bool> fenceTerminalNativeSharePayloadUntilAcknowledgedForTest({
|
|
required NativeShareProcessingFence fence,
|
|
required SharedPayloadProcessResult result,
|
|
required SharedPayload payload,
|
|
required Future<bool> Function(String id) acknowledge,
|
|
}) async {
|
|
if (result == SharedPayloadProcessResult.retry || payload.id == null) {
|
|
return false;
|
|
}
|
|
fence.markTerminal(payload);
|
|
final acknowledged =
|
|
await acknowledgeNativeSharePayloadAfterProcessingForTest(
|
|
result: result,
|
|
payload: payload,
|
|
acknowledge: acknowledge,
|
|
);
|
|
if (acknowledged) fence.release(payload.id!);
|
|
return acknowledged;
|
|
}
|
|
|
|
/// Holds a pending shared payload until the app is ready (e.g., authed + model loaded)
|
|
final pendingSharedPayloadProvider =
|
|
NotifierProvider<PendingSharedPayloadNotifier, SharedPayload?>(
|
|
PendingSharedPayloadNotifier.new,
|
|
);
|
|
|
|
class PendingSharedPayloadNotifier extends Notifier<SharedPayload?> {
|
|
@override
|
|
SharedPayload? build() => null;
|
|
|
|
void set(SharedPayload? payload) => state = payload;
|
|
}
|
|
|
|
class SharedAttachmentImportStatus {
|
|
final String? id;
|
|
final int expectedFileCount;
|
|
final bool isInProgress;
|
|
final List<String> errors;
|
|
final bool preparedComposer;
|
|
|
|
const SharedAttachmentImportStatus({
|
|
this.id,
|
|
required this.expectedFileCount,
|
|
required this.isInProgress,
|
|
this.errors = const [],
|
|
this.preparedComposer = false,
|
|
});
|
|
|
|
factory SharedAttachmentImportStatus.fromMap(dynamic value) {
|
|
if (value is! Map) return nullStatus;
|
|
|
|
final rawId = value['id'];
|
|
final rawCount = value['expectedFileCount'];
|
|
final rawInProgress = value['isInProgress'];
|
|
final rawErrors = value['errors'];
|
|
|
|
return SharedAttachmentImportStatus(
|
|
id: rawId is String && rawId.isNotEmpty ? rawId : null,
|
|
expectedFileCount: rawCount is num ? rawCount.toInt() : 0,
|
|
isInProgress: rawInProgress == true,
|
|
errors: rawErrors is List
|
|
? rawErrors
|
|
.whereType<String>()
|
|
.where((error) => error.trim().isNotEmpty)
|
|
.toList(growable: false)
|
|
: const [],
|
|
);
|
|
}
|
|
|
|
SharedAttachmentImportStatus copyWith({
|
|
String? id,
|
|
int? expectedFileCount,
|
|
bool? isInProgress,
|
|
List<String>? errors,
|
|
bool? preparedComposer,
|
|
}) {
|
|
return SharedAttachmentImportStatus(
|
|
id: id ?? this.id,
|
|
expectedFileCount: expectedFileCount ?? this.expectedFileCount,
|
|
isInProgress: isInProgress ?? this.isInProgress,
|
|
errors: errors ?? this.errors,
|
|
preparedComposer: preparedComposer ?? this.preparedComposer,
|
|
);
|
|
}
|
|
|
|
static const nullStatus = SharedAttachmentImportStatus(
|
|
expectedFileCount: 0,
|
|
isInProgress: false,
|
|
);
|
|
|
|
bool get hasPlaceholders => isInProgress && expectedFileCount > 0;
|
|
bool get hasErrors => errors.isNotEmpty;
|
|
bool get isEmpty => !hasPlaceholders && !hasErrors;
|
|
}
|
|
|
|
final sharedAttachmentImportStatusProvider =
|
|
NotifierProvider<
|
|
SharedAttachmentImportStatusNotifier,
|
|
SharedAttachmentImportStatus
|
|
>(SharedAttachmentImportStatusNotifier.new);
|
|
|
|
class SharedAttachmentImportStatusNotifier
|
|
extends Notifier<SharedAttachmentImportStatus> {
|
|
@override
|
|
SharedAttachmentImportStatus build() =>
|
|
SharedAttachmentImportStatus.nullStatus;
|
|
|
|
void set(SharedAttachmentImportStatus status) {
|
|
final keepPreparedComposer =
|
|
state.preparedComposer && state.id != null && state.id == status.id;
|
|
state = keepPreparedComposer
|
|
? status.copyWith(preparedComposer: true)
|
|
: status;
|
|
}
|
|
|
|
void markComposerPrepared(String? id) {
|
|
if (id != null && state.id != id) {
|
|
return;
|
|
}
|
|
state = state.copyWith(preparedComposer: true);
|
|
}
|
|
|
|
void clear({String? id}) {
|
|
if (id != null && state.id != id) {
|
|
return;
|
|
}
|
|
state = SharedAttachmentImportStatus.nullStatus;
|
|
}
|
|
}
|
|
|
|
/// Initializes listening to OS share intents and handles them
|
|
final shareReceiverInitializerProvider = Provider<void>((ref) {
|
|
// Only mobile platforms handle OS share intents
|
|
if (kIsWeb) return;
|
|
if (!(Platform.isAndroid || Platform.isIOS)) return;
|
|
|
|
var isProcessingPending = false;
|
|
Timer? retryTimer;
|
|
Timer? stagedSharePollTimer;
|
|
var isPollingStagedShare = false;
|
|
final preparedShareImportIds = <String>{};
|
|
final reportedShareImportErrorIds = <String>{};
|
|
final nativeProcessingFence = NativeShareProcessingFence();
|
|
final nativeReceiptRegistry = ref.read(_nativeShareReceiptRegistryProvider);
|
|
late Future<void> Function() maybeProcessPending;
|
|
late Future<void> Function() maybeStartNativeShareImportPolling;
|
|
|
|
Future<void> releaseAcknowledgedNativeReceipts(String payloadId) async {
|
|
final receiptKeys = nativeReceiptRegistry.keysFor(payloadId);
|
|
if (receiptKeys.isEmpty) return;
|
|
try {
|
|
await ref
|
|
.read(mediaUploadControllerProvider)
|
|
.releaseNativeShareReceipts(receiptKeys);
|
|
nativeReceiptRegistry.release(payloadId);
|
|
} catch (error, stackTrace) {
|
|
// Native ack already won the cross-store ordering race. Leaving the
|
|
// conservative held receipts can leak rows, but cannot duplicate an
|
|
// upload or delete bytes that still need a durable owner.
|
|
DebugLogger.error(
|
|
'native-share-receipt-release-failed',
|
|
scope: 'share/receiver',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
|
|
void scheduleProcessPending([
|
|
Duration delay = const Duration(milliseconds: 150),
|
|
]) {
|
|
retryTimer?.cancel();
|
|
retryTimer = Timer(delay, () {
|
|
unawaited(maybeProcessPending());
|
|
});
|
|
}
|
|
|
|
Future<void> resetSharedIntent() async {
|
|
try {
|
|
await ReceiveSharingIntent.instance.reset();
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to reset shared intent',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<String?> takePendingAndroidMultipleShareText() async {
|
|
if (!Platform.isAndroid) return null;
|
|
|
|
try {
|
|
return await _androidShareTextChannel.invokeMethod<String>(
|
|
'takePendingMultipleShareText',
|
|
);
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to get Android share text',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> hasPendingAndroidStagedSharePayload() async {
|
|
if (!Platform.isAndroid) return false;
|
|
|
|
try {
|
|
return await _androidShareTextChannel.invokeMethod<bool>(
|
|
'hasPendingStagedSharePayload',
|
|
) ??
|
|
false;
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to check Android staged share payload',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<SharedPayload?> takePendingAndroidStagedSharePayload() async {
|
|
if (!Platform.isAndroid) return null;
|
|
|
|
try {
|
|
return await peekPendingNativeSharePayloadForTest(
|
|
_androidShareTextChannel,
|
|
);
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to get Android staged share payload',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<SharedAttachmentImportStatus>
|
|
getPendingNativeShareImportStatus() async {
|
|
if (!(Platform.isAndroid || Platform.isIOS)) {
|
|
return SharedAttachmentImportStatus.nullStatus;
|
|
}
|
|
|
|
try {
|
|
final raw = await _androidShareTextChannel.invokeMethod<Object?>(
|
|
'pendingShareImportStatus',
|
|
);
|
|
return SharedAttachmentImportStatus.fromMap(raw);
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to get native share import status',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return SharedAttachmentImportStatus.nullStatus;
|
|
}
|
|
}
|
|
|
|
Future<SharedPayload?> takePendingNativeShareImportPayload() async {
|
|
if (Platform.isAndroid) {
|
|
return await takePendingAndroidStagedSharePayload();
|
|
}
|
|
if (!Platform.isIOS) return null;
|
|
|
|
try {
|
|
return await peekPendingNativeSharePayloadForTest(
|
|
_androidShareTextChannel,
|
|
);
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to get native share import payload',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> ackPendingNativeShareImportPayload(String id) async {
|
|
if (!(Platform.isAndroid || Platform.isIOS)) return false;
|
|
|
|
for (var attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
final acknowledged = await ackPendingNativeSharePayloadForTest(
|
|
_androidShareTextChannel,
|
|
id,
|
|
);
|
|
if (acknowledged) return true;
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to acknowledge native share payload',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
if (attempt == 0) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> clearNativeShareImportStatus(String? id) async {
|
|
if (!(Platform.isAndroid || Platform.isIOS)) return;
|
|
|
|
try {
|
|
await _androidShareTextChannel.invokeMethod<void>(
|
|
'clearShareImportStatus',
|
|
id == null ? null : {'id': id},
|
|
);
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to clear native share import status',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> releaseOrphanedNativeShareReceipts() async {
|
|
// Receipts stranded by a process death between the native payload ack and
|
|
// receipt release have no process-local registry entry and would leak
|
|
// forever. Release them only when native storage authoritatively reports
|
|
// nothing pending; the controller snapshots candidates before this
|
|
// confirmation runs, so a receipt for a still-pending payload — the exact
|
|
// race receipts exist to prevent — can never be released here.
|
|
if (ref.read(pendingSharedPayloadProvider)?.id != null) return;
|
|
try {
|
|
final released = await ref
|
|
.read(mediaUploadControllerProvider)
|
|
.releaseOrphanedNativeShareReceipts(
|
|
confirmNoPendingNativePayloads: () async {
|
|
if (ref.read(pendingSharedPayloadProvider)?.id != null) {
|
|
return false;
|
|
}
|
|
final status = await getPendingNativeShareImportStatus();
|
|
if (status.isInProgress) return false;
|
|
if (await takePendingNativeShareImportPayload() != null) {
|
|
return false;
|
|
}
|
|
if (Platform.isAndroid &&
|
|
await hasPendingAndroidStagedSharePayload()) {
|
|
return false;
|
|
}
|
|
return ref.read(pendingSharedPayloadProvider)?.id == null;
|
|
},
|
|
);
|
|
if (released > 0) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: released orphaned native share receipts',
|
|
scope: 'share/receiver',
|
|
data: {'count': released},
|
|
);
|
|
}
|
|
} catch (error, stackTrace) {
|
|
// Conservative: held receipts remain for a later GC pass. Leaked rows
|
|
// cannot duplicate an upload or delete bytes with a live durable owner.
|
|
DebugLogger.error(
|
|
'native-share-receipt-gc-failed',
|
|
scope: 'share/receiver',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
|
|
void showShareImportErrors(SharedAttachmentImportStatus status) {
|
|
if (!status.hasErrors) return;
|
|
|
|
final context = NavigationService.context;
|
|
if (context == null) {
|
|
return;
|
|
}
|
|
|
|
final newErrors = <String>[];
|
|
for (final error in status.errors) {
|
|
final trimmed = error.trim();
|
|
if (trimmed.isEmpty) continue;
|
|
final reportKey = '${status.id ?? 'native-share'}\n$trimmed';
|
|
if (reportedShareImportErrorIds.add(reportKey)) {
|
|
newErrors.add(trimmed);
|
|
}
|
|
}
|
|
if (newErrors.isEmpty) return;
|
|
|
|
final message = newErrors.length == 1
|
|
? newErrors.first
|
|
: newErrors.take(3).join('\n');
|
|
ScaffoldMessenger.maybeOf(context)
|
|
?..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(content: Text(message), duration: const Duration(seconds: 5)),
|
|
);
|
|
}
|
|
|
|
Future<void> prepareShareImportUi(SharedAttachmentImportStatus status) async {
|
|
if (!status.hasPlaceholders) return;
|
|
|
|
final navState = ref.read(authNavigationStateProvider);
|
|
final model = ref.read(selectedModelProvider);
|
|
if (navState != AuthNavigationState.authenticated || model == null) {
|
|
return;
|
|
}
|
|
|
|
final importId = status.id;
|
|
if (importId != null && preparedShareImportIds.contains(importId)) {
|
|
return;
|
|
}
|
|
|
|
if (NavigationService.currentRoute != Routes.chat) {
|
|
await NavigationService.navigateToChat();
|
|
await Future<void>.delayed(const Duration(milliseconds: 75));
|
|
}
|
|
|
|
if (NavigationService.currentRoute == Routes.chat) {
|
|
startNewChat(ref);
|
|
if (importId != null) {
|
|
preparedShareImportIds.add(importId);
|
|
}
|
|
ref
|
|
.read(sharedAttachmentImportStatusProvider.notifier)
|
|
.markComposerPrepared(importId);
|
|
}
|
|
}
|
|
|
|
Future<SharedAttachmentImportStatus> updateNativeShareImportStatus() async {
|
|
final status = await getPendingNativeShareImportStatus();
|
|
ref.read(sharedAttachmentImportStatusProvider.notifier).set(status);
|
|
showShareImportErrors(status);
|
|
await prepareShareImportUi(status);
|
|
return status;
|
|
}
|
|
|
|
// Listen for app readiness: authenticated, model available, and chat visible.
|
|
maybeProcessPending = () async {
|
|
if (isProcessingPending) return;
|
|
|
|
final pending = ref.read(pendingSharedPayloadProvider);
|
|
if (pending == null || !pending.hasAnything) return;
|
|
final isAcknowledgementRetry = !nativeProcessingFence.shouldProcess(
|
|
pending,
|
|
);
|
|
if (!isAcknowledgementRetry) {
|
|
final navState = ref.read(authNavigationStateProvider);
|
|
final model = ref.read(selectedModelProvider);
|
|
if (navState != AuthNavigationState.authenticated || model == null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
isProcessingPending = true;
|
|
try {
|
|
if (isAcknowledgementRetry) {
|
|
final pendingId = pending.id!;
|
|
final acknowledged = await ackPendingNativeShareImportPayload(
|
|
pendingId,
|
|
);
|
|
if (!acknowledged) {
|
|
scheduleProcessPending(const Duration(milliseconds: 300));
|
|
return;
|
|
}
|
|
await releaseAcknowledgedNativeReceipts(pendingId);
|
|
nativeProcessingFence.release(pendingId);
|
|
ref
|
|
.read(sharedAttachmentImportStatusProvider.notifier)
|
|
.clear(id: pendingId);
|
|
await clearNativeShareImportStatus(pendingId);
|
|
|
|
final latestPending = ref.read(pendingSharedPayloadProvider);
|
|
if (identical(latestPending, pending)) {
|
|
ref.read(pendingSharedPayloadProvider.notifier).set(null);
|
|
await resetSharedIntent();
|
|
} else if (latestPending != null && latestPending.hasAnything) {
|
|
scheduleProcessPending();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (NavigationService.currentRoute != Routes.chat) {
|
|
await NavigationService.navigateToChat();
|
|
await Future<void>.delayed(const Duration(milliseconds: 75));
|
|
}
|
|
|
|
if (NavigationService.currentRoute != Routes.chat) {
|
|
scheduleProcessPending();
|
|
return;
|
|
}
|
|
|
|
final result = await _processPayload(ref, pending);
|
|
if (result == SharedPayloadProcessResult.retry) {
|
|
scheduleProcessPending(const Duration(milliseconds: 300));
|
|
return;
|
|
}
|
|
|
|
if (pending.id != null) {
|
|
final acknowledged =
|
|
await fenceTerminalNativeSharePayloadUntilAcknowledgedForTest(
|
|
fence: nativeProcessingFence,
|
|
result: result,
|
|
payload: pending,
|
|
acknowledge: ackPendingNativeShareImportPayload,
|
|
);
|
|
if (!acknowledged) {
|
|
DebugLogger.warning(
|
|
'native-share-payload-ack-deferred',
|
|
scope: 'share/receiver',
|
|
);
|
|
scheduleProcessPending(const Duration(milliseconds: 300));
|
|
return;
|
|
}
|
|
await releaseAcknowledgedNativeReceipts(pending.id!);
|
|
ref
|
|
.read(sharedAttachmentImportStatusProvider.notifier)
|
|
.clear(id: pending.id);
|
|
await clearNativeShareImportStatus(pending.id);
|
|
}
|
|
|
|
final latestPending = ref.read(pendingSharedPayloadProvider);
|
|
if (identical(latestPending, pending)) {
|
|
ref.read(pendingSharedPayloadProvider.notifier).set(null);
|
|
await resetSharedIntent();
|
|
} else if (latestPending != null && latestPending.hasAnything) {
|
|
scheduleProcessPending();
|
|
} else {
|
|
await resetSharedIntent();
|
|
}
|
|
} finally {
|
|
isProcessingPending = false;
|
|
}
|
|
};
|
|
|
|
Future<void> setPendingFromSharedMedia(List<SharedMediaFile> media) async {
|
|
final extraText = await takePendingAndroidMultipleShareText();
|
|
final payload = SharedPayload.fromSharedFiles(media, extraText: extraText);
|
|
if (!payload.hasAnything) {
|
|
if (media.isNotEmpty || (extraText?.trim().isNotEmpty ?? false)) {
|
|
unawaited(resetSharedIntent());
|
|
}
|
|
return;
|
|
}
|
|
ref.read(pendingSharedPayloadProvider.notifier).set(payload);
|
|
unawaited(maybeProcessPending());
|
|
}
|
|
|
|
Future<bool> setPendingFromNativeShareImportPayload() async {
|
|
final payload = await takePendingNativeShareImportPayload();
|
|
if (payload == null) return false;
|
|
|
|
final current = ref.read(pendingSharedPayloadProvider);
|
|
if (payload.id != null && current?.id == payload.id) return true;
|
|
ref.read(pendingSharedPayloadProvider.notifier).set(payload);
|
|
unawaited(maybeProcessPending());
|
|
return true;
|
|
}
|
|
|
|
maybeStartNativeShareImportPolling = () async {
|
|
if (!(Platform.isAndroid || Platform.isIOS)) return;
|
|
|
|
final initialStatus = await updateNativeShareImportStatus();
|
|
final hasPendingAndroidPayload =
|
|
Platform.isAndroid && await hasPendingAndroidStagedSharePayload();
|
|
if (!initialStatus.hasPlaceholders &&
|
|
!initialStatus.hasErrors &&
|
|
!hasPendingAndroidPayload) {
|
|
final consumedPendingPayload =
|
|
await setPendingFromNativeShareImportPayload();
|
|
if (!consumedPendingPayload) {
|
|
// Native storage is fully drained: any terminal receipt-held row left
|
|
// behind by a crash between ack and release is now provably orphaned.
|
|
unawaited(releaseOrphanedNativeShareReceipts());
|
|
}
|
|
return;
|
|
}
|
|
|
|
stagedSharePollTimer?.cancel();
|
|
var attempts = 0;
|
|
|
|
Future<void> tick(Timer? timer) async {
|
|
if (isPollingStagedShare) return;
|
|
attempts += 1;
|
|
isPollingStagedShare = true;
|
|
try {
|
|
final status = await updateNativeShareImportStatus();
|
|
final consumed = await setPendingFromNativeShareImportPayload();
|
|
final hasPendingAndroidPayload =
|
|
Platform.isAndroid && await hasPendingAndroidStagedSharePayload();
|
|
final didTimeout =
|
|
!consumed &&
|
|
attempts >= _nativeShareImportMaxPollAttempts &&
|
|
(status.hasPlaceholders || hasPendingAndroidPayload);
|
|
final shouldContinue =
|
|
!consumed &&
|
|
attempts < _nativeShareImportMaxPollAttempts &&
|
|
(status.hasPlaceholders || hasPendingAndroidPayload);
|
|
if (!shouldContinue) {
|
|
timer?.cancel();
|
|
if (identical(stagedSharePollTimer, timer)) {
|
|
stagedSharePollTimer = null;
|
|
}
|
|
if (didTimeout && status.hasPlaceholders) {
|
|
final errors = [
|
|
...status.errors,
|
|
if (!status.errors.contains(_nativeShareImportTimedOutMessage))
|
|
_nativeShareImportTimedOutMessage,
|
|
];
|
|
final failedStatus = status.copyWith(
|
|
isInProgress: false,
|
|
errors: errors,
|
|
);
|
|
ref
|
|
.read(sharedAttachmentImportStatusProvider.notifier)
|
|
.set(failedStatus);
|
|
showShareImportErrors(failedStatus);
|
|
await clearNativeShareImportStatus(status.id);
|
|
} else if (!status.isInProgress && !consumed && !status.isEmpty) {
|
|
ref
|
|
.read(sharedAttachmentImportStatusProvider.notifier)
|
|
.clear(id: status.id);
|
|
await clearNativeShareImportStatus(status.id);
|
|
}
|
|
}
|
|
} finally {
|
|
isPollingStagedShare = false;
|
|
}
|
|
}
|
|
|
|
unawaited(tick(null));
|
|
stagedSharePollTimer = Timer.periodic(
|
|
const Duration(milliseconds: 500),
|
|
(timer) => unawaited(tick(timer)),
|
|
);
|
|
};
|
|
|
|
// React when auth/model changes to process a queued share
|
|
ref.listen<AuthNavigationState>(
|
|
authNavigationStateProvider,
|
|
(prev, next) => unawaited(maybeProcessPending()),
|
|
);
|
|
ref.listen(
|
|
selectedModelProvider,
|
|
(prev, next) => unawaited(maybeProcessPending()),
|
|
);
|
|
ref.listen<SharedPayload?>(
|
|
pendingSharedPayloadProvider,
|
|
(prev, next) => unawaited(maybeProcessPending()),
|
|
);
|
|
// Each per-server attachment queue restore is a GC opportunity for receipts
|
|
// orphaned by a crash between the native payload ack and receipt release.
|
|
// The GC itself re-verifies that native storage holds no pending payloads.
|
|
ref.listen(attachmentUploadQueueProvider, (prev, next) {
|
|
if (next != null) {
|
|
unawaited(releaseOrphanedNativeShareReceipts());
|
|
}
|
|
});
|
|
|
|
try {
|
|
void onRouteChanged() => unawaited(maybeProcessPending());
|
|
final routeListenable = NavigationService.router.routeInformationProvider;
|
|
routeListenable.addListener(onRouteChanged);
|
|
ref.onDispose(() {
|
|
routeListenable.removeListener(onRouteChanged);
|
|
});
|
|
} catch (_) {
|
|
// The router may not be attached during early provider initialization.
|
|
// Auth/model/pending listeners and delayed retries still drive processing.
|
|
}
|
|
|
|
ref.onDispose(() {
|
|
retryTimer?.cancel();
|
|
stagedSharePollTimer?.cancel();
|
|
if (Platform.isAndroid || Platform.isIOS) {
|
|
_androidShareTextChannel.setMethodCallHandler(null);
|
|
}
|
|
});
|
|
|
|
if (Platform.isAndroid || Platform.isIOS) {
|
|
_androidShareTextChannel.setMethodCallHandler((call) async {
|
|
if (call.method == 'stagedSharePayloadReady') {
|
|
await maybeStartNativeShareImportPolling();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Also poll once shortly after navigation settles to ensure ChatPage is ready
|
|
Future.delayed(
|
|
const Duration(milliseconds: 150),
|
|
() => unawaited(maybeProcessPending()),
|
|
);
|
|
|
|
// Hook into the native share plugin after a short defer to avoid startup
|
|
// contention while Flutter is settling its first frame.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|
|
|
// Handle initial share when app is cold-started via Share
|
|
Future.microtask(() async {
|
|
try {
|
|
await maybeStartNativeShareImportPolling();
|
|
if (Platform.isAndroid) {
|
|
final media = await ReceiveSharingIntent.instance.getInitialMedia();
|
|
await setPendingFromSharedMedia(media);
|
|
}
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to get initial shared media',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
});
|
|
|
|
// Handle subsequent shares while app is alive
|
|
final StreamSubscription<List<SharedMediaFile>>? streamSub =
|
|
Platform.isAndroid
|
|
? ReceiveSharingIntent.instance.getMediaStream().listen((media) {
|
|
unawaited(
|
|
(() async {
|
|
try {
|
|
await maybeStartNativeShareImportPolling();
|
|
await setPendingFromSharedMedia(media);
|
|
} catch (error) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: failed to parse shared media',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
})(),
|
|
);
|
|
})
|
|
: null;
|
|
|
|
// Ensure cleanup
|
|
ref.onDispose(() async {
|
|
await streamSub?.cancel();
|
|
});
|
|
});
|
|
});
|
|
|
|
enum _SharedFileKind { text, file }
|
|
|
|
_SharedFileKind _sharedFileKind(SharedMediaFile file) {
|
|
switch (file.type) {
|
|
case SharedMediaType.text:
|
|
case SharedMediaType.url:
|
|
return _SharedFileKind.text;
|
|
case SharedMediaType.image:
|
|
case SharedMediaType.video:
|
|
case SharedMediaType.file:
|
|
return _SharedFileKind.file;
|
|
}
|
|
}
|
|
|
|
String? _normalizeSharedFilePath(String? value) {
|
|
final trimmed = value?.trim();
|
|
if (trimmed == null || trimmed.isEmpty) return null;
|
|
|
|
if (trimmed.startsWith('file://')) {
|
|
try {
|
|
return Uri.parse(trimmed).toFilePath();
|
|
} catch (_) {
|
|
return trimmed.replaceFirst('file://', '');
|
|
}
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
Future<SharedPayloadProcessResult> _processPayload(
|
|
dynamic ref,
|
|
SharedPayload payload, {
|
|
NativeShareStagingRootResolver? nativeStagingRootResolver,
|
|
SharedIncomingFileStager? incomingFileStager,
|
|
SharedStagedFileRollback? stagedFileRollback,
|
|
LegacyPluginSourceRootResolver? legacyPluginSourceRootResolver,
|
|
}) async {
|
|
final rollbackRegistry = ref.read(_deferredSharedRollbackRegistryProvider);
|
|
final deferredRollback = rollbackRegistry.batchFor(payload);
|
|
if (deferredRollback != null) {
|
|
final rolledBack = await _rollbackSharedAttachmentBatchForRetry(
|
|
deferredRollback,
|
|
);
|
|
if (!rolledBack) {
|
|
// Do not create another copy set while this payload still owns artifacts
|
|
// from its previous attempt.
|
|
return SharedPayloadProcessResult.retry;
|
|
}
|
|
rollbackRegistry.release(payload, deferredRollback);
|
|
}
|
|
|
|
_PreparedSharedAttachmentBatch? attachmentBatch;
|
|
try {
|
|
final text = payload.text?.trim();
|
|
final hasText = text != null && text.isNotEmpty;
|
|
var attachments = const <LocalAttachment>[];
|
|
|
|
// Resolve and stage the complete attachment set before touching chat
|
|
// state. Transient filesystem/ownership failures leave the payload
|
|
// retryable, while confirmed missing or invalid files are consumed.
|
|
if (payload.filePaths.isNotEmpty) {
|
|
final svc = ref.read(fileAttachmentServiceProvider);
|
|
if (svc != null) {
|
|
final preparation = await _prepareSharedAttachments(
|
|
payload.filePaths,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
incomingFileStager: incomingFileStager,
|
|
stagedFileRollback: stagedFileRollback,
|
|
isLegacyPluginPayload: payload.isLegacyPluginPayload,
|
|
legacyPluginSourceRootResolver: legacyPluginSourceRootResolver,
|
|
);
|
|
attachmentBatch = preparation.batch;
|
|
if (preparation.shouldRetry) {
|
|
if (attachmentBatch.hasRollbackArtifacts) {
|
|
rollbackRegistry.retain(payload, attachmentBatch);
|
|
}
|
|
return SharedPayloadProcessResult.retry;
|
|
}
|
|
attachments = attachmentBatch.attachments;
|
|
} else {
|
|
return SharedPayloadProcessResult.retry;
|
|
}
|
|
}
|
|
|
|
if (attachments.isEmpty && !hasText) {
|
|
await attachmentBatch?.commit();
|
|
DebugLogger.log(
|
|
'ShareReceiver: consumed shared payload with no usable content',
|
|
scope: 'share/receiver',
|
|
);
|
|
return SharedPayloadProcessResult.consumed;
|
|
}
|
|
|
|
// Start a fresh chat context but do NOT auto-send. If the native import
|
|
// already prepared the composer for this payload, keep the user's draft.
|
|
final importStatus = ref.read(sharedAttachmentImportStatusProvider);
|
|
final shouldUsePreparedComposer =
|
|
payload.id != null &&
|
|
importStatus.id == payload.id &&
|
|
importStatus.preparedComposer;
|
|
if (!shouldUsePreparedComposer) {
|
|
startNewChat(ref);
|
|
}
|
|
|
|
// Prefer attaching files to the composer so user can add text before sending
|
|
if (attachments.isNotEmpty) {
|
|
final nativePayloadId = payload.id;
|
|
final selectedModel = ref.read(selectedModelProvider);
|
|
// Hermes/direct attachments are prepared into composer memory only, so
|
|
// the crash-safe durable receipt path cannot own them — the media
|
|
// controller rejects the import (NativeShareDurableOwnershipUnavailable)
|
|
// and the payload would otherwise retry forever without ever appearing.
|
|
// Route these models through the composer-first local path instead: the
|
|
// share becomes usable immediately and the native payload is
|
|
// acknowledged normally. A process death after the ack can lose the
|
|
// in-memory attachment, which matches the pre-receipt behavior.
|
|
final requiresLocalComposerOwnership =
|
|
selectedModel != null &&
|
|
(isHermesModel(selectedModel) ||
|
|
hasReservedDirectIdentity(selectedModel));
|
|
if (nativePayloadId == null || requiresLocalComposerOwnership) {
|
|
// The legacy sharing-intent plugin has no durable exact-ID record to
|
|
// dedupe across a process death. Keep its established composer-first
|
|
// ownership policy; native Android/iOS imports always carry an ID and
|
|
// use the crash-safe path below (unless a local-only Hermes/direct
|
|
// model owns the composer, per above).
|
|
ref.read(attachedFilesProvider.notifier).addFiles(attachments);
|
|
await attachmentBatch!.commit();
|
|
for (final prepared in attachmentBatch.prepared) {
|
|
final attachment = prepared.attachment;
|
|
unawaited(
|
|
ref
|
|
.read(mediaUploadControllerProvider)
|
|
.upload(
|
|
filePath: attachment.file.path,
|
|
fileName: attachment.displayName,
|
|
fileSize: prepared.fileSize,
|
|
)
|
|
.catchError(
|
|
(Object error) => DebugLogger.warning(
|
|
'shared-attachment-upload-failed',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
final mediaUpload = ref.read(mediaUploadControllerProvider);
|
|
final receiptKeys = <String>[];
|
|
for (var index = 0; index < attachmentBatch!.prepared.length; index++) {
|
|
final prepared = attachmentBatch.prepared[index];
|
|
final attachment = prepared.attachment;
|
|
final acceptance = await mediaUpload.enqueueNativeShareUpload(
|
|
filePath: attachment.file.path,
|
|
fileName: attachment.displayName,
|
|
fileSize: prepared.fileSize,
|
|
identity: NativeShareUploadIdentity(
|
|
payloadId: nativePayloadId,
|
|
itemOrdinal: index,
|
|
),
|
|
publishAttachment: attachment,
|
|
);
|
|
receiptKeys.add(acceptance.receiptKey);
|
|
if (acceptance.providedPathOwned) {
|
|
// A later item may still fail. Transfer only this accepted copy so
|
|
// whole-batch rollback cannot unlink a path already owned by Drift.
|
|
attachmentBatch.transferStagedPath(attachment.file.path);
|
|
}
|
|
}
|
|
|
|
// Same-process/restart joins may have created extra staging copies that
|
|
// were intentionally not republished. Remove those before committing
|
|
// native sources; a failed unlink keeps the payload retryable.
|
|
if (attachmentBatch.hasRollbackArtifacts) {
|
|
final duplicatesRemoved =
|
|
await _rollbackSharedAttachmentBatchForRetry(attachmentBatch);
|
|
if (!duplicatesRemoved) {
|
|
rollbackRegistry.retain(payload, attachmentBatch);
|
|
return SharedPayloadProcessResult.retry;
|
|
}
|
|
}
|
|
|
|
// Every attachment now has a persisted row/receipt (or joined one).
|
|
// Only this whole-payload boundary permits native source cleanup + ack.
|
|
await attachmentBatch.commit();
|
|
ref
|
|
.read(_nativeShareReceiptRegistryProvider)
|
|
.retain(nativePayloadId, receiptKeys);
|
|
}
|
|
}
|
|
|
|
// Prefill text in the composer (do not auto-send) and request focus
|
|
if (hasText) {
|
|
ref.read(prefilledInputTextProvider.notifier).set(text);
|
|
// Bump focus trigger to ensure input focuses after navigation/build
|
|
final current = ref.read(inputFocusTriggerProvider);
|
|
ref.read(inputFocusTriggerProvider.notifier).set(current + 1);
|
|
}
|
|
// Do NOT create a server chat here. The chat is created on first send
|
|
// (with server syncing + title generation) in chat_providers.dart.
|
|
return SharedPayloadProcessResult.processed;
|
|
} catch (error) {
|
|
final wasCommitted = attachmentBatch?.isCommitted ?? false;
|
|
if (!wasCommitted) {
|
|
final batch = attachmentBatch;
|
|
if (batch != null) {
|
|
final rolledBack = await _rollbackSharedAttachmentBatchForRetry(batch);
|
|
if (!rolledBack) {
|
|
rollbackRegistry.retain(payload, batch);
|
|
}
|
|
}
|
|
}
|
|
DebugLogger.warning(
|
|
'shared-payload-processing-failed',
|
|
scope: 'share/receiver',
|
|
data: {
|
|
'errorType': error.runtimeType.toString(),
|
|
'attachmentsCommitted': wasCommitted,
|
|
},
|
|
);
|
|
return wasCommitted
|
|
? SharedPayloadProcessResult.processed
|
|
: SharedPayloadProcessResult.retry;
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<SharedPayloadProcessResult> processSharedPayloadForTest(
|
|
ProviderContainer container,
|
|
SharedPayload payload, {
|
|
NativeShareStagingRootResolver? nativeStagingRootResolver,
|
|
SharedIncomingFileStager? incomingFileStager,
|
|
SharedStagedFileRollback? stagedFileRollback,
|
|
LegacyPluginSourceRootResolver? legacyPluginSourceRootResolver,
|
|
}) {
|
|
return _processPayload(
|
|
container,
|
|
payload,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
incomingFileStager: incomingFileStager,
|
|
stagedFileRollback: stagedFileRollback,
|
|
legacyPluginSourceRootResolver: legacyPluginSourceRootResolver,
|
|
);
|
|
}
|
|
|
|
Future<Directory?> _resolveLegacyPluginSourceRoot({
|
|
NativeShareStagingRootResolver? nativeStagingRootResolver,
|
|
}) async {
|
|
if (Platform.isAndroid) {
|
|
// receive_sharing_intent materializes Android content URIs into the app's
|
|
// cache directory, exposed to Dart as Directory.systemTemp.
|
|
return Directory.systemTemp;
|
|
}
|
|
if (!Platform.isIOS) return null;
|
|
|
|
Directory? nativeStagingRoot;
|
|
if (nativeStagingRootResolver != null) {
|
|
nativeStagingRoot = await nativeStagingRootResolver();
|
|
} else {
|
|
final rawPath = await _androidShareTextChannel.invokeMethod<String>(
|
|
'shareStagingDirectoryPath',
|
|
);
|
|
if (rawPath != null && rawPath.trim().isNotEmpty) {
|
|
nativeStagingRoot = Directory(path.normalize(path.absolute(rawPath)));
|
|
}
|
|
}
|
|
if (nativeStagingRoot == null ||
|
|
path.basename(path.normalize(nativeStagingRoot.path)) !=
|
|
shareStagingDirectoryName) {
|
|
return null;
|
|
}
|
|
// The legacy plugin writes direct children beside the native Conduit staging
|
|
// directory in the same App Group container.
|
|
return Directory(path.dirname(path.normalize(nativeStagingRoot.path)));
|
|
}
|
|
|
|
Future<_SharedAttachmentPreparation> _prepareSharedAttachments(
|
|
List<String> filePaths, {
|
|
NativeShareStagingRootResolver? nativeStagingRootResolver,
|
|
SharedIncomingFileStager? incomingFileStager,
|
|
SharedStagedFileRollback? stagedFileRollback,
|
|
bool isLegacyPluginPayload = false,
|
|
LegacyPluginSourceRootResolver? legacyPluginSourceRootResolver,
|
|
}) async {
|
|
final candidates = <_SharedFileCandidate>[];
|
|
final prepared = <_PreparedSharedAttachment>[];
|
|
final copiedStagingPaths = <String>[];
|
|
final copiedSourceLeases = <IncomingSharedSourceDeletionLease>[];
|
|
|
|
_PreparedSharedAttachmentBatch buildBatch() {
|
|
return _PreparedSharedAttachmentBatch(
|
|
prepared: prepared,
|
|
copiedStagingPaths: copiedStagingPaths,
|
|
copiedSourceLeases: copiedSourceLeases,
|
|
rollbackStagedFile:
|
|
stagedFileRollback ??
|
|
(filePath) => deleteShareStagingFileWithResult(
|
|
filePath,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
),
|
|
cleanupCopiedSource: deleteIncomingSharedSourceIfSafe,
|
|
);
|
|
}
|
|
|
|
Future<_SharedAttachmentPreparation> retryPreparation() async {
|
|
final batch = buildBatch();
|
|
await _rollbackSharedAttachmentBatchForRetry(batch);
|
|
return (shouldRetry: true, batch: batch);
|
|
}
|
|
|
|
// Validate every candidate before applying the attachment cap. Missing,
|
|
// malformed, or oversized entries must not consume a slot that a later
|
|
// valid file could use.
|
|
for (final filePath in filePaths) {
|
|
final displayName = path.basename(filePath);
|
|
|
|
final FileSystemEntityType initialType;
|
|
try {
|
|
initialType = await FileSystemEntity.type(filePath, followLinks: false);
|
|
} on FileSystemException catch (error) {
|
|
DebugLogger.warning(
|
|
'shared-file-type-inspection-failed',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return retryPreparation();
|
|
}
|
|
if (initialType == FileSystemEntityType.notFound) {
|
|
continue;
|
|
}
|
|
if (initialType != FileSystemEntityType.file) {
|
|
DebugLogger.warning(
|
|
'shared-file-rejected-non-regular',
|
|
scope: 'share/receiver',
|
|
data: {'type': initialType.toString()},
|
|
);
|
|
continue;
|
|
}
|
|
|
|
final int fileSize;
|
|
try {
|
|
fileSize = await File(filePath).length();
|
|
} catch (error) {
|
|
FileSystemEntityType currentType;
|
|
try {
|
|
currentType = await FileSystemEntity.type(filePath, followLinks: false);
|
|
} on FileSystemException {
|
|
currentType = initialType;
|
|
}
|
|
if (currentType == FileSystemEntityType.notFound) {
|
|
continue;
|
|
}
|
|
DebugLogger.warning(
|
|
'shared-file-size-inspection-failed',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return retryPreparation();
|
|
}
|
|
|
|
final ownership = await classifyShareStagingPath(
|
|
filePath,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
);
|
|
if (ownership == ShareStagingPathOwnership.indeterminate) {
|
|
DebugLogger.warning(
|
|
'shared-file-ownership-indeterminate',
|
|
scope: 'share/receiver',
|
|
);
|
|
return retryPreparation();
|
|
}
|
|
|
|
final isImage = _isSharedImagePath(displayName);
|
|
if (isImage &&
|
|
!validateFileSize(fileSize, _maxSharedImageAttachmentSizeMB)) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: rejected oversized shared image',
|
|
scope: 'share/receiver',
|
|
data: {'size': fileSize, 'maxSizeMB': _maxSharedImageAttachmentSizeMB},
|
|
);
|
|
final cleaned = await _cleanupRejectedSharedFile(
|
|
filePath,
|
|
ownership: ownership,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
);
|
|
if (!cleaned) {
|
|
return retryPreparation();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (candidates.length >= _maxSharedAttachmentCount) {
|
|
DebugLogger.log(
|
|
'ShareReceiver: rejected shared file after count cap',
|
|
scope: 'share/receiver',
|
|
data: {'maxCount': _maxSharedAttachmentCount},
|
|
);
|
|
final cleaned = await _cleanupRejectedSharedFile(
|
|
filePath,
|
|
ownership: ownership,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
);
|
|
if (!cleaned) {
|
|
return retryPreparation();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
IncomingSharedSourceDeletionLease? sourceDeletionLease;
|
|
if (isLegacyPluginPayload &&
|
|
ownership == ShareStagingPathOwnership.notOwned) {
|
|
try {
|
|
final trustedRoot =
|
|
await (legacyPluginSourceRootResolver ??
|
|
() => _resolveLegacyPluginSourceRoot(
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
))
|
|
.call();
|
|
if (trustedRoot != null) {
|
|
sourceDeletionLease = await createIncomingSharedSourceDeletionLease(
|
|
filePath,
|
|
trustedPluginRoot: trustedRoot,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// Cleanup authority is optional. A failed root lookup must retain the
|
|
// source, not turn an otherwise valid share into a retry loop.
|
|
DebugLogger.log(
|
|
'ShareReceiver: plugin source lease unavailable',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
|
|
candidates.add(
|
|
_SharedFileCandidate(
|
|
filePath: filePath,
|
|
displayName: displayName,
|
|
fileSize: fileSize,
|
|
sourceDeletionLease: sourceDeletionLease,
|
|
),
|
|
);
|
|
}
|
|
|
|
for (final candidate in candidates) {
|
|
try {
|
|
final result = incomingFileStager != null
|
|
? await incomingFileStager(candidate.filePath)
|
|
: await stageIncomingSharedFileWithResult(
|
|
candidate.filePath,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
deletePluginSourceAfterCopy: false,
|
|
);
|
|
if (result.copied) {
|
|
copiedStagingPaths.add(result.file.path);
|
|
final sourceLease = candidate.sourceDeletionLease;
|
|
if (sourceLease != null) copiedSourceLeases.add(sourceLease);
|
|
}
|
|
prepared.add(
|
|
_PreparedSharedAttachment(
|
|
attachment: LocalAttachment(
|
|
file: result.file,
|
|
displayName: candidate.displayName,
|
|
),
|
|
fileSize: candidate.fileSize,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
DebugLogger.warning(
|
|
'shared-file-staging-failed',
|
|
scope: 'share/receiver',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
return retryPreparation();
|
|
}
|
|
}
|
|
|
|
return (shouldRetry: false, batch: buildBatch());
|
|
}
|
|
|
|
Future<bool> _cleanupRejectedSharedFile(
|
|
String filePath, {
|
|
required ShareStagingPathOwnership ownership,
|
|
NativeShareStagingRootResolver? nativeStagingRootResolver,
|
|
}) async {
|
|
switch (ownership) {
|
|
case ShareStagingPathOwnership.indeterminate:
|
|
return false;
|
|
case ShareStagingPathOwnership.notOwned:
|
|
// This source is not owned by Conduit, so rejection is complete without
|
|
// unlinking it. Do not turn a deliberately retained caller-owned file
|
|
// into a retry loop merely because there was no cleanup to perform.
|
|
return true;
|
|
case ShareStagingPathOwnership.owned:
|
|
final result = await deleteShareStagingFileWithResult(
|
|
filePath,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
);
|
|
if (result == ShareStagingFileCleanupResult.removed) return true;
|
|
if (result == ShareStagingFileCleanupResult.failed) return false;
|
|
// A concurrent unlink makes a formerly owned path resolve as not-owned.
|
|
// Confirm absence before consuming the native payload.
|
|
try {
|
|
return await FileSystemEntity.type(filePath, followLinks: false) ==
|
|
FileSystemEntityType.notFound;
|
|
} on FileSystemException {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isSharedImagePath(String filePath) {
|
|
return allSupportedImageFormats.contains(
|
|
path.extension(filePath).toLowerCase(),
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<List<LocalAttachment>> validSharedAttachmentsForTest(
|
|
List<String> filePaths, {
|
|
NativeShareStagingRootResolver? nativeStagingRootResolver,
|
|
SharedIncomingFileStager? incomingFileStager,
|
|
bool isLegacyPluginPayload = false,
|
|
LegacyPluginSourceRootResolver? legacyPluginSourceRootResolver,
|
|
}) async {
|
|
final preparation = await _prepareSharedAttachments(
|
|
filePaths,
|
|
nativeStagingRootResolver: nativeStagingRootResolver,
|
|
incomingFileStager: incomingFileStager,
|
|
isLegacyPluginPayload: isLegacyPluginPayload,
|
|
legacyPluginSourceRootResolver: legacyPluginSourceRootResolver,
|
|
);
|
|
if (preparation.shouldRetry) {
|
|
throw const FileSystemException(
|
|
'Shared attachment preparation requires retry',
|
|
);
|
|
}
|
|
await preparation.batch.commit();
|
|
return preparation.batch.attachments;
|
|
}
|