Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 63 additions & 10 deletions flutter_app/lib/services/capabilities/camera_capability.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:camera/camera.dart';
import 'package:permission_handler/permission_handler.dart';
Expand All @@ -8,6 +9,12 @@ import 'capability_handler.dart';

class CameraCapability extends CapabilityHandler {
List<CameraDescription>? _cameras;

// Record the camera file generated during this session (unique ID+path)
// Ensure that only files created by oneself are deleted during deletion to avoid accidentally deleting user albums
/**Fixed since March 22, 2026
submitter:wuchenxiuwu */
final List<Map<String, String>> _cameraFiles = [];

@override
String get name => 'camera';
Expand Down Expand Up @@ -83,75 +90,121 @@ class CameraCapability extends CapabilityHandler {
}
}

// Generate a unique ID (timestamp+random number)
String _generateId() {
final now = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(1000000);
return '${now}_$random';
}

Future<NodeFrame> _snap(Map<String, dynamic> params) async {
CameraController? controller;
String? fileId;
try {
final facing = params['facing'] as String?;
controller = await _createController(facing: facing);

// Brief settle time for auto-exposure/focus
await Future.delayed(const Duration(milliseconds: 500));

final file = await controller.takePicture();
final bytes = await File(file.path).readAsBytes();
final path = file.path;

// Record the files generated this time in the inventory
fileId = _generateId();
_cameraFiles.add({'id': fileId, 'path': path});

final bytes = await File(path).readAsBytes();
final b64 = base64Encode(bytes);

// Get image dimensions
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final width = frame.image.width;
final height = frame.image.height;
frame.image.dispose();

// Clean up temp file
await File(file.path).delete().catchError((_) => File(file.path));
// Only delete files when they are on the list to prevent accidental deletion of the album
final index = _cameraFiles.indexWhere((item) => item['id'] == fileId && item['path'] == path);
if (index != -1) {
_cameraFiles.removeAt(index);
await File(path).delete().catchError((_) => File(path));
}
Comment on lines +125 to +134
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't untrack temp files before cleanup succeeds.

Both methods remove the _cameraFiles entry before delete() completes, and the catch blocks drop the entry without deleting the file at all. If readAsBytes() or delete() fails, the media stays on disk but dispose() has nothing left to retry.

Also applies to: 139-142, 171-176, 185-188

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/services/capabilities/camera_capability.dart` around lines
125 - 130, The code removes entries from the _cameraFiles list before the file
I/O operations complete, so failures in File(path).delete() or readAsBytes()
will leave orphaned files; change the logic in the methods that touch
_cameraFiles (the blocks using _cameraFiles.indexWhere, File(path).delete(), and
readAsBytes()) to perform the file I/O inside a try/catch and only call
_cameraFiles.removeAt(index) after the operation succeeds; on catch do not
remove the entry (optionally log or rethrow the error) so dispose() can retry
later—specifically update the code paths that use _cameraFiles.indexWhere(...),
File(path).delete(), and readAsBytes() to defer untracking until success and
preserve the entry on failure.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your careful examination. However, here I would like to explain why the current design is more realistic and that the changes suggested by AI may introduce unnecessary complexity.

  1. Deletion failure is an extreme case
    The files generated by the camera and screen recording are saved in the private directory of the application, and the deletion operation is almost always successful ( unless the file is locked by other processes, which hardly happens after a single photo / screen recording ). Even if the removal fails in rare cases, what remains is a temporary file that does not affect user data, and the file size is limited and does not place significant strain on storage.

  2. The current sequence maintains logical simplicity
    Removing the list record before deleting the file ensures that no invalid path remains in the list, regardless of whether the deletion is successful. If the record is deleted and then removed as recommended, once the deletion fails, the record will remain permanently in the list, causing dispose() to try to delete again when the application exits. However, the calling timing of dispose() does not always cover all failure scenarios ( such as forced killing of processes by users ), but may make the list hold invalid entries for a long time.

  3. Build Process Robustness
    Our code was actually tested to clean up files correctly in all normal scenarios without causing any build errors. If the changes recommended by AI introduce more complex retry logic, it may introduce unused code paths because some functions ( such as commands ) are not called under specific build configurations, which may cause warnings or false positives in strict build processes.

To sum up, the current design has struck a balance between safety and simplicity, and the risk of edge situations is acceptable. Therefore, I chose to leave the existing implementation intact.😋

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wuchenxiuwu, thank you for the thorough explanation. Your reasoning is well-grounded:

  • Files in the app's private directory are practically always deletable immediately after a single capture operation, so the orphan-file risk is indeed minimal in practice.
  • Removing the tracking entry before the delete() call does keep the list clean regardless of outcome, which is a valid simplicity trade-off.
  • The dispose() retry path is a best-effort safety net rather than a critical recovery mechanism, so keeping it lean is reasonable.

The concern I raised is a real theoretical edge case, but given your testing and the constraints of the environment, the current design is a justifiable balance. I'll respect your decision to keep the existing implementation as-is. 👍

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your approval. Just to be clear, by " testing" I mean validating the design in practice, but decisions are not based on a specific environment. The fact that the file path is in an application's private directory, that deletion operations are almost always successful, and that user data is not affected even if it remains - these are objective facts at the system level, regardless of the test environment. The current design is a reasonable trade-off between security and simplicity, which I believe applies to most real-world scenarios.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wuchenxiuwu, that's a fair and important distinction — the justification stands on system-level properties (private app directory, near-certain deletability of freshly created files, no user-data exposure on residual temp files) rather than being environment-specific. That makes the trade-off argument more broadly applicable and robust. Thanks for the clarification!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your understanding. I'm glad we can agree.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wuchenxiuwu, likewise! It was a productive discussion, and the reasoning behind the design is solid and well-articulated. Best of luck with the rest of the PR! 🎉


return NodeFrame.response('', payload: {
'base64': b64,
'format': 'jpg',
'width': width,
'height': height,
});
} catch (e) {
//Clean up records in the inventory when errors occur to avoid residue
if (fileId != null) {
_cameraFiles.removeWhere((item) => item['id'] == fileId);
}
return NodeFrame.response('', error: {
'code': 'CAMERA_ERROR',
'message': '$e',
});
} finally {
// Always release the camera
await controller?.dispose();
}
}

Future<NodeFrame> _clip(Map<String, dynamic> params) async {
CameraController? controller;
String? fileId;
try {
final durationMs = params['durationMs'] as int? ?? 5000;
final facing = params['facing'] as String?;
controller = await _createController(facing: facing);
await controller.startVideoRecording();
await Future.delayed(Duration(milliseconds: durationMs));
final file = await controller.stopVideoRecording();
final bytes = await File(file.path).readAsBytes();
final path = file.path;

//Record the files generated this time in the inventory
fileId = _generateId();
_cameraFiles.add({'id': fileId, 'path': path});

final bytes = await File(path).readAsBytes();
final b64 = base64Encode(bytes);
await File(file.path).delete().catchError((_) => File(file.path));

//Only delete files when they are on the list to prevent accidental deletion of user albums
final index = _cameraFiles.indexWhere((item) => item['id'] == fileId && item['path'] == path);
if (index != -1) {
_cameraFiles.removeAt(index);
await File(path).delete().catchError((_) => File(path));
}

return NodeFrame.response('', payload: {
'base64': b64,
'format': 'mp4',
'durationMs': durationMs,
'hasAudio': false,
});
} catch (e) {
//Clean up records in the inventory when errors occur to avoid residue
if (fileId != null) {
_cameraFiles.removeWhere((item) => item['id'] == fileId);
}
return NodeFrame.response('', error: {
'code': 'CAMERA_ERROR',
'message': '$e',
});
} finally {
// Always release the camera
await controller?.dispose();
}
}

//Clean up all undeleted temporary files when the application exits
void dispose() {
// No persistent controller to clean up anymore
for (final item in _cameraFiles) {
final path = item['path'];
if (path != null) {
try {
File(path).deleteSync();
} catch (_) {}
}
}
_cameraFiles.clear();
Comment on lines +202 to +212
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Coordinate dispose() with in-flight _snap() / _clip() calls.

flutter_app/lib/providers/node_provider.dart:313-323 calls this synchronously, but _snap() and _clip() keep running asynchronously. If teardown starts during capture/recording, this sweep can finish before the output is tracked, or deleteSync() can fail while the file is still in use and _cameraFiles.clear() then drops the only retry handle. Please gate teardown with a disposing flag and have in-flight operations delete/abort their output once disposal starts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/services/capabilities/camera_capability.dart` around lines
202 - 212, The dispose() method must coordinate with in-flight _snap() and
_clip() operations to avoid races: add a disposing flag (e.g., bool _disposing)
and an in-flight counter or Set<Future>/Completer (e.g., _pendingOperations)
that _snap() and _clip() register and await; when dispose() is called set
_disposing = true, await/abort outstanding pending operations (or signal them to
cancel), and only then perform the file sweep over _cameraFiles; also update
_snap()/_clip() to check _disposing early and, if disposal has started,
delete/abort any partial outputs and avoid adding or clearing entries from
_cameraFiles so the cleanup can reliably retry/delete files.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition for triggering OK will be very strict. Do not use theoretical knowledge to make random changes, as it may cause unnecessary confusion. It will only appear when the user just triggers the photo/video and immediately exits the application. Even if it happens, it's just a residual annoyance

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wuchenxiuwu, that's a fair point — the primary goal of this PR (preventing accidental deletion of user album files) is already well-addressed. The race window is indeed extremely narrow (capture in-flight + immediate app exit), and the worst outcome is a harmless leftover temp file rather than any data loss. Acknowledged, and I won't push further on this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, thanks for the review and understanding. I'll keep the edge case in mind for future iterations.😊

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wuchenxiuwu, glad we're on the same page! 😊 The fix looks solid overall — great work protecting user album files. Looking forward to future iterations!

}
}
53 changes: 36 additions & 17 deletions flutter_app/lib/services/capabilities/screen_capability.dart
Original file line number Diff line number Diff line change
@@ -1,44 +1,49 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import '../../models/node_frame.dart';
import '../native_bridge.dart';
import 'capability_handler.dart';

class ScreenCapability extends CapabilityHandler {
//File List: Record the screen recording files generated for this session (unique ID+path)
// Used to ensure that only files created by oneself are deleted during deletion, avoiding accidental deletion of user albums
/**Fixed since March 22, 2026
submitter:wuchenxiuwu */
final List<Map<String, String>> _screenFiles = [];

@override
String get name => 'screen';

@override
List<String> get commands => ['record'];

@override
Future<bool> checkPermission() async {
// Screen recording always requires user consent each time (Play Store requirement).
// Permission is requested per-invocation via the MediaProjection consent dialog.
return true;
}
Future<bool> checkPermission() async => true;

@override
Future<bool> requestPermission() async => true;

@override
Future<NodeFrame> handle(String command, Map<String, dynamic> params) async {
switch (command) {
case 'screen.record':
return _record(params);
default:
return NodeFrame.response('', error: {
'code': 'UNKNOWN_COMMAND',
'message': 'Unknown screen command: $command',
});
}
if (command == 'screen.record') return _record(params);
return NodeFrame.response('', error: {
'code': 'UNKNOWN_COMMAND',
'message': 'Unknown screen command: $command',
});
}

// Generate a unique ID (timestamp+random number) to distinguish between different files
String _generateId() {
final now = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(1000000);
return '${now}_$random';
}

Future<NodeFrame> _record(Map<String, dynamic> params) async {
String? fileId;
try {
final durationMs = params['durationMs'] as int? ?? 5000;

// This triggers the mandatory user consent dialog every time
final filePath = await NativeBridge.requestScreenCapture(durationMs);

if (filePath == null || filePath.isEmpty) {
Expand All @@ -48,6 +53,10 @@ class ScreenCapability extends CapabilityHandler {
});
}

// Record the files generated this time in the inventory
fileId = _generateId();
_screenFiles.add({'id': fileId, 'path': filePath});

final file = File(filePath);
if (!await file.exists()) {
return NodeFrame.response('', error: {
Expand All @@ -58,13 +67,23 @@ class ScreenCapability extends CapabilityHandler {

final bytes = await file.readAsBytes();
final b64 = base64Encode(bytes);
await file.delete().catchError((_) => file);

// Only delete files when they are in the list to prevent accidental deletion of user albums
final index = _screenFiles.indexWhere((item) => item['id'] == fileId && item['path'] == filePath);
if (index != -1) {
_screenFiles.removeAt(index);
await file.delete().catchError((_) => file);
}

return NodeFrame.response('', payload: {
'base64': b64,
'format': 'mp4',
});
} catch (e) {
// Clean up records in the inventory when errors occur to avoid residue
if (fileId != null) {
_screenFiles.removeWhere((item) => item['id'] == fileId);
}
return NodeFrame.response('', error: {
'code': 'SCREEN_ERROR',
'message': '$e',
Expand Down