Skip to content

Commit a678548

Browse files
authored
Recover screenshots after app reinstall or update (#408)
Screenshots vanished from feedback submitted after an app reinstall or update. On iOS the data container GUID is reassigned on every install (Apple TN2406), so the absolute path persisted at capture time points at a directory that no longer exists, even though the file itself was migrated under the same name. retrieveAllPendingItems now rebuilds each on-disk screenshot path from the current documents directory and the persisted file name. The directory is resolved only when an item actually has a screenshot on disk, so screenshot-less feedback never touches the path_provider channel.
1 parent 38d4c88 commit a678548

4 files changed

Lines changed: 133 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- **Fix** Recover screenshots when the app's data directory moves between launches. On iOS the data container GUID is reassigned on every reinstall or update (Apple TN2406), invalidating the absolute screenshot path stored at capture time and dropping the screenshot from the feedback. Paths are now rebuilt against the current directory when loading pending feedback in `retrieveAllPendingItems`.
6+
37
## 2.7.0
48

59
- **Breaking** Raise minimum SDK to Dart 3.3 / Flutter 3.19 [#401](https://github.com/wiredashio/wiredash-sdk/pull/401)

lib/src/feedback/data/pending_feedback_item_storage.dart

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,65 @@ class PendingFeedbackItemStorage {
7070
}
7171
}
7272
}
73-
return parsed.toList();
73+
74+
// Rebuild screenshot paths against the current directory, but only resolve
75+
// that directory when at least one item actually has a screenshot on disk.
76+
// Most feedback has no screenshot, and resolving the directory hits a
77+
// platform channel (getApplicationDocumentsDirectory) that we don't want to
78+
// require on every read.
79+
if (!parsed.any(_hasOnDiskScreenshot)) {
80+
return parsed;
81+
}
82+
final screenshotsDir = await _getScreenshotStorageDirectoryPath();
83+
return parsed
84+
.map((item) => _withCurrentScreenshotPaths(item, screenshotsDir))
85+
.toList();
86+
}
87+
88+
bool _hasOnDiskScreenshot(PendingFeedbackItem item) {
89+
final attachments = item.feedbackItem.attachments;
90+
if (attachments == null) {
91+
return false;
92+
}
93+
return attachments.any((a) => a is Screenshot && a.file.isOnDisk);
94+
}
95+
96+
/// Rebuilds on-disk screenshot paths against the *current* screenshot
97+
/// directory.
98+
///
99+
/// The absolute path to a file inside the app's container is not stable
100+
/// between launches: on iOS the data container's GUID is reassigned when the
101+
/// app is updated or reinstalled (Apple TN2406), so a path persisted earlier
102+
/// may point at a directory that no longer exists even though the file itself
103+
/// was migrated and still lives under the same name. Apple's guidance is to
104+
/// resolve the standard directory at runtime rather than store an absolute
105+
/// path, so we reconstruct the path from [screenshotsDir] (the current
106+
/// directory) and the persisted file name.
107+
PendingFeedbackItem _withCurrentScreenshotPaths(
108+
PendingFeedbackItem item,
109+
String screenshotsDir,
110+
) {
111+
final attachments = item.feedbackItem.attachments;
112+
if (attachments == null || attachments.isEmpty) {
113+
return item;
114+
}
115+
final updated = <PersistedAttachment>[];
116+
for (final attachment in attachments) {
117+
if (attachment is Screenshot && attachment.file.isOnDisk) {
118+
final fileName = _fs.path.basename(attachment.file.pathToFile!);
119+
final currentPath =
120+
_fs.path.normalize(_fs.path.join(screenshotsDir, fileName));
121+
updated.add(
122+
attachment.copyWith(file: FileDataEventuallyOnDisk.file(currentPath)),
123+
);
124+
} else {
125+
updated.add(attachment);
126+
}
127+
}
128+
return PendingFeedbackItem(
129+
id: item.id,
130+
feedbackItem: item.feedbackItem.copyWith(attachments: updated),
131+
);
74132
}
75133

76134
/// Saves [item] and [screenshot] in the persistent storage.

test/feedback/data/pending_feedback_item_storage_test.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,54 @@ void main() {
8585
]);
8686
});
8787

88+
test(
89+
'reconstructs the screenshot path against the current directory '
90+
'when the container path changed (iOS reinstall, Apple TN2406)',
91+
() async {
92+
// Persist a screenshot under the "old" container directory.
93+
await fileSystem.directory('/old/Documents').create(recursive: true);
94+
final oldStorage = PendingFeedbackItemStorage(
95+
fileSystem: fileSystem,
96+
sharedPreferencesProvider: () async => prefs,
97+
dirPathProvider: () async => '/old/Documents',
98+
wuidGenerator: wuidGenerator,
99+
);
100+
final saved = await oldStorage.addPendingItem(
101+
createFeedback(
102+
attachments: [
103+
PersistedAttachment.screenshot(
104+
file: FileDataEventuallyOnDisk.inMemory(kTransparentImage),
105+
),
106+
],
107+
),
108+
);
109+
expect(
110+
saved.feedbackItem.attachments!.single.file,
111+
FileDataEventuallyOnDisk.file('/old/Documents/00000000.png'),
112+
);
113+
114+
// The OS migrates the file to a new container on reinstall; the old
115+
// absolute path no longer resolves.
116+
await fileSystem.directory('/new/Documents').create(recursive: true);
117+
await fileSystem
118+
.file('/old/Documents/00000000.png')
119+
.rename('/new/Documents/00000000.png');
120+
121+
// The next launch sees a new container directory but the same persisted
122+
// pending items.
123+
final newStorage = PendingFeedbackItemStorage(
124+
fileSystem: fileSystem,
125+
sharedPreferencesProvider: () async => prefs,
126+
dirPathProvider: () async => '/new/Documents',
127+
wuidGenerator: wuidGenerator,
128+
);
129+
final retrieved = await newStorage.retrieveAllPendingItems();
130+
final file = retrieved.single.feedbackItem.attachments!.single.file;
131+
expect(
132+
file, FileDataEventuallyOnDisk.file('/new/Documents/00000000.png'));
133+
expect(fileSystem.file(file.pathToFile).existsSync(), isTrue);
134+
});
135+
88136
test('store a second item without overriding the first one', () async {
89137
final firstFeedback = createFeedback(
90138
attachments: [

test/offline_feedback_test.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:io';
33

44
// ignore: depend_on_referenced_packages
55
import 'package:async/async.dart' show ResultFuture;
6+
import 'package:flutter/services.dart';
67
import 'package:flutter_test/flutter_test.dart';
78
import 'package:shared_preferences/shared_preferences.dart';
89
import 'package:wiredash/src/core/network/wiredash_api.dart';
@@ -42,6 +43,9 @@ void main() {
4243

4344
// insert feedback in v2 format with attachment
4445
final tempDir = Directory.systemTemp.createTempSync();
46+
// The screenshot lives in the app documents directory; loading pending
47+
// feedback rebuilds its path against the current directory.
48+
_mockApplicationDocumentsDirectory(tester, tempDir.path);
4549
File('${tempDir.path}/image.png').writeAsStringSync('test img content');
4650
final json = fullJsonV2;
4751
// ignore: avoid_dynamic_calls
@@ -88,6 +92,9 @@ void main() {
8892

8993
// insert feedback in v3 format with attachment
9094
final tempDir = Directory.systemTemp.createTempSync();
95+
// The screenshot lives in the app documents directory; loading pending
96+
// feedback rebuilds its path against the current directory.
97+
_mockApplicationDocumentsDirectory(tester, tempDir.path);
9198
File('${tempDir.path}/image.png').writeAsStringSync('test img content');
9299
final json = fullJsonV3;
93100
// ignore: avoid_dynamic_calls
@@ -130,6 +137,21 @@ void main() {
130137
});
131138
}
132139

140+
/// Routes `getApplicationDocumentsDirectory()` to [path] for the duration of a
141+
/// test, so the storage layer can rebuild persisted screenshot paths against a
142+
/// known directory.
143+
void _mockApplicationDocumentsDirectory(WidgetTester tester, String path) {
144+
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
145+
const MethodChannel('plugins.flutter.io/path_provider'),
146+
(call) async {
147+
if (call.method == 'getApplicationDocumentsDirectory') {
148+
return path;
149+
}
150+
return null;
151+
},
152+
);
153+
}
154+
133155
Map get fullJsonV2 => {
134156
'id': 'abc123',
135157
'version': 2,

0 commit comments

Comments
 (0)