Skip to content

Conversation

@austin047
Copy link
Collaborator

Description

Implement a background isolate for removing stale media files not attached to a media object

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

@austin047 austin047 marked this pull request as ready for review February 5, 2026 11:27
@austin047 austin047 requested a review from nfebe February 5, 2026 11:28
@sourceant
Copy link

sourceant bot commented Feb 5, 2026

Code Review Summary

The PR introduces a background isolate for cleaning up orphaned media files and standardizes the project's build flavors (development, staging, production) for both Android and iOS. It also updates the Flutter SDK version to 3.38.9 across various configuration files.

🚀 Key Improvements

  • Background isolate for file system cleanup in lib/core/services/orphaned_media_cleanup_service.dart prevents UI jank.
  • Full flavor support in android/app/build.gradle and ios/Runner.xcodeproj allows for separate app IDs and names per environment.
  • Consolidated Firebase configuration management using flavor-specific GoogleService-Info.plist files.

💡 Minor Suggestions

  • Fix typo in .github/ENV_AND_SECRETS.md.
  • Use asynchronous directory listing in the cleanup isolate.
  • Remove auto-generated Gradle problem reports from the repository (android/build/reports/...).

🚨 Critical Issues

  • The file android/build/reports/problems/problems-report.html was accidentally committed. This is a build artifact and should be removed from the PR and added to .gitignore.

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment on lines +70 to +72
? row.path.split('.').last.toLowerCase()
: 'bin';
validPaths.add(p.normalize(p.join(cacheDir, 'file_${row.id}.$ext')));
Copy link

Choose a reason for hiding this comment

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

While functionally correct, this logic assumes that row.path for non-null IDs will always be in a format that split('.').last can correctly extract the extension (e.g., file_123.jpg). If row.path could contain other dots or be a full path already, this might be fragile. However, given how MediaFileLocalDataSourceImpl.insertMediaForTransaction constructs the path as uuid.ext, this seems safe within the current architecture. A small comment clarifying this assumption could be helpful.

Suggested change
? row.path.split('.').last.toLowerCase()
: 'bin';
validPaths.add(p.normalize(p.join(cacheDir, 'file_${row.id}.$ext')));
final ext = p.extension(row.path).toLowerCase();
validPaths.add(p.normalize(p.join(cacheDir, 'file_${row.id}$ext')));

@austin047 austin047 force-pushed the feat/remove-stale-media branch from 9dda49b to ef19a2d Compare February 8, 2026 11:37
Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment on lines +26 to +36
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
if (!path.startsWith(normalizedRoot)) continue;
if (!validSet.contains(path)) {
try {
entity.deleteSync();
deleted.add(path);
} catch (_) {}
}
}
Copy link

Choose a reason for hiding this comment

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

When listing files recursively, it is safer to check if the entity exists before attempting a delete, especially in a concurrent environment (even though this is an isolate). Additionally, listSync can throw if permissions are denied or the directory is deleted during iteration.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
if (!path.startsWith(normalizedRoot)) continue;
if (!validSet.contains(path)) {
try {
entity.deleteSync();
deleted.add(path);
} catch (_) {}
}
}
try {
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
if (!path.startsWith(normalizedRoot)) continue;
if (!validSet.contains(path)) {
if (entity.existsSync()) {
entity.deleteSync();
deleted.add(path);
}
}
}
} catch (_) {
// Handle listSync errors (e.g. Permission denied)
}

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

sendPort.send(_resultMap(deleted, null));
return;
}
for (final entity in dir.listSync(recursive: true)) {
Copy link

Choose a reason for hiding this comment

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

Using recursive: true on the media root can be dangerous if the directory structure grows or if symlinks are involved. Since media files are expected in specific flat or shallow structures, consider limiting the recursion or being more explicit about the directories to scan.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
for (final entity in dir.listSync(recursive: false)) {


# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
Copy link

Choose a reason for hiding this comment

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

Generating a 32-character base64 password for a temporary keychain is good, but Ensure the password does not contain characters that might break shell commands if not properly quoted elsewhere. A hex string is often safer for shell interpolation.

Suggested change
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
KEYCHAIN_PASSWORD=$(openssl rand -hex 16)

Comment on lines +333 to +346
// Text(
// LocaleKeys.phoneNumber.tr(),
// style: TextStyle(
// fontSize: 16.sp,
// fontWeight: FontWeight.w700,
// ),
// ),
// SizedBox(height: 8.h),
// CustomPhoneField(
// onChanged: (number) {
// _phoneNumber = number.completeNumber;
// },
// ),
// SizedBox(height: 16.h),
Copy link

Choose a reason for hiding this comment

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

This large block of commented-out code should be removed to maintain clean code standards. If phone registration is intended for the future, it should be managed via feature branches rather than leaving dead code in the UI file.

Suggested change
// Text(
// LocaleKeys.phoneNumber.tr(),
// style: TextStyle(
// fontSize: 16.sp,
// fontWeight: FontWeight.w700,
// ),
// ),
// SizedBox(height: 8.h),
// CustomPhoneField(
// onChanged: (number) {
// _phoneNumber = number.completeNumber;
// },
// ),
// SizedBox(height: 16.h),
SizedBox(height: 16.h),

Comment on lines +31 to +35
try {
entity.deleteSync();
deleted.add(path);
} catch (_) {}
}
Copy link

Choose a reason for hiding this comment

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

Swallowing all exceptions during file deletion is generally discouraged. While we don't want the isolate to crash, we should at least log which files failed to delete to help diagnose permission issues or file locks.

Suggested change
try {
entity.deleteSync();
deleted.add(path);
} catch (_) {}
}
try {
entity.deleteSync();
deleted.add(path);
} catch (e) {
// In a real app, you might want to report this failure to a sync log
print('Failed to delete orphaned file $path: $e');
}

Copy link
Contributor

@nfebe nfebe left a comment

Choose a reason for hiding this comment

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

Checks are failing and please check sourceant change request.

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

// _phoneNumber = number.completeNumber;
// },
// ),
// SizedBox(height: 16.h),
Copy link

Choose a reason for hiding this comment

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

Passing a hardcoded empty string for the phone number during registration will lead to data integrity issues or validation failures on the backend. If phone registration is temporarily disabled, ensure the backend is prepared for null values or use a properly validated field.

Suggested change
// SizedBox(height: 16.h),
phone: _phoneNumber ?? "",

sendPort.send(_resultMap(deleted, null));
return;
}
for (final entity in dir.listSync(recursive: true)) {
Copy link

Choose a reason for hiding this comment

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

Using listSync on a directory can lead to performance issues or crashes if the directory contains a very large number of files, as it loads all entities into memory at once. Consider using the asynchronous list() stream even within an isolate to keep the memory footprint low.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
await for (final entity in dir.list(recursive: true)) {

jobs:
build:
runs-on: macos-latest
timeout-minutes: 30
Copy link

Choose a reason for hiding this comment

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

iOS builds often take significantly longer than 30 minutes, especially during the 'Xcode archive' or 'pod install' phases for complex Flutter projects. A 30-minute timeout might cause the workflow to fail prematurely in a busy runner environment.

Suggested change
timeout-minutes: 30
timeout-minutes: 60

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

sendPort.send(_resultMap(deleted, null));
return;
}
for (final entity in dir.listSync(recursive: true)) {
Copy link

Choose a reason for hiding this comment

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

When listing recursively, you should ensure that only files within specific 'media' subdirectories are targeted, or explicitly exclude system directories (like .DS_Store on iOS or .thumbnails). Additionally, entity.deleteSync() can fail if the file is currently locked by the OS or another part of the app. It's better to log these failures specifically rather than swallowing them with an empty catch.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
if (!path.startsWith(normalizedRoot)) continue;
if (!validSet.contains(path)) {
try {
if (p.basename(path).startsWith('.')) continue;
entity.deleteSync();
deleted.add(path);
} catch (e) {
debugPrint('Failed to delete $path: $e');
}
}
}


void _scheduleOrphanedMediaCleanup() {
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 3), () async {
Copy link

Choose a reason for hiding this comment

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

Using a hardcoded 3-second delay is a 'magic number' and can be unreliable. It might trigger while the app is still performing heavy initial sync or DB migrations. Consider using a more deterministic trigger or moving this to a background task runner if available.

Suggested change
Future.delayed(const Duration(seconds: 3), () async {
Future.delayed(const Duration(minutes: 1), () async {

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.


void _scheduleOrphanedMediaCleanup() {
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 3), () async {
Copy link

Choose a reason for hiding this comment

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

Scheduling cleanup with a hardcoded delay of 3 seconds is fragile. If the app is under heavy initialization load, this might still compete for resources. Consider using a LifecycleListener or triggering it when the app goes to the background.

Suggested change
Future.delayed(const Duration(seconds: 3), () async {
Future.delayed(const Duration(minutes: 1), () async {

lastName: lastNameController.text,
username: usernameController.text,
phone: _phoneNumber ?? "",
phone: '', // Phone input commented out
Copy link

Choose a reason for hiding this comment

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

Hardcoding an empty string for the phone field during registration might cause validation errors on the backend if the API expects a null value or a valid formatted number when the field is optional.

Suggested change
phone: '', // Phone input commented out
phone: null,

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

sendPort.send(_resultMap(deleted, null));
return;
}
for (final entity in dir.listSync(recursive: true)) {
Copy link

Choose a reason for hiding this comment

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

When iterating and deleting files, listSync can occasionally fail if a file is moved/deleted by another process during iteration. Additionally, checking entity.existsSync() before deletion is safer.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
if (!path.startsWith(normalizedRoot)) continue;
if (!validSet.contains(path)) {
try {
if (entity.existsSync()) {
entity.deleteSync();
deleted.add(path);
}
} catch (_) {}
}
}

lastName: lastNameController.text,
username: usernameController.text,
phone: _phoneNumber ?? "",
phone: '', // Phone input commented out
Copy link

Choose a reason for hiding this comment

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

Passing an empty string for the phone number might bypass intended validation or cause logic errors on the backend if a phone is expected. Consider making this parameter nullable in the Cubit if it is optional.

Suggested change
phone: '', // Phone input commented out
phone: '', // TODO: Re-enable phone field in a future update

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

sendPort.send(_resultMap(deleted, null));
return;
}
for (final entity in dir.listSync(recursive: true)) {
Copy link

Choose a reason for hiding this comment

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

Using listSync on a potentially large media directory might block the isolate longer than necessary. While this is already running in a background isolate, using the asynchronous dir.list(recursive: true) stream would be more idiomatic and memory-efficient for very large file collections.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
await for (final entity in dir.list(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
if (!path.startsWith(normalizedRoot)) continue;
if (!validSet.contains(path)) {
try {
await entity.delete();
deleted.add(path);
} catch (_) {}
}
}

void orphanedMediaCleanupIsolateEntry(List<dynamic> args) {
final sendPort = args[0] as SendPort;
final mediaRootPath = args[1] as String;
final validPaths = (args[2] as List<dynamic>).cast<String>();
Copy link

Choose a reason for hiding this comment

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

Passing a List<String> and converting it to a Set inside the isolate is good for lookup performance. However, you can pass the Set directly to Isolate.spawn to avoid the manual conversion step inside the isolate entry point.

Suggested change
final validPaths = (args[2] as List<dynamic>).cast<String>();
final validSet = args[2] as Set<String>;


void _scheduleOrphanedMediaCleanup() {
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 3), () async {
Copy link

Choose a reason for hiding this comment

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

A hardcoded 3-second delay might be arbitrary. Consider if this cleanup should run every time the app starts or if it should be throttled (e.g., once a day) using PreferenceManager to avoid unnecessary disk I/O on every launch.

Suggested change
Future.delayed(const Duration(seconds: 3), () async {
Future.delayed(const Duration(minutes: 1), () async {

@austin047 austin047 force-pushed the feat/remove-stale-media branch from d52702f to 40ea54e Compare February 9, 2026 17:21
Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

sendPort.send(_resultMap(deleted, null));
return;
}
for (final entity in dir.listSync(recursive: true)) {
Copy link

Choose a reason for hiding this comment

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

Using dir.listSync can be dangerous for performance even inside an isolate if the directory contains thousands of files, as it loads all entries into memory at once. It is better to use dir.list to stream the entities.

Suggested change
for (final entity in dir.listSync(recursive: true)) {
for (final entity in dir.listSync(recursive: true, followLinks: false)) {

lastName: lastNameController.text,
username: usernameController.text,
phone: _phoneNumber ?? "",
phone: '', // Phone input commented out
Copy link

Choose a reason for hiding this comment

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

The phone field is hardcoded to an empty string here, while the UI for phone input was commented out. If this is a temporary change for the 'feat', please ensure it is tracked for restoration.

Suggested change
phone: '', // Phone input commented out
phone: '', // TODO: Restore phone input

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

}
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File) continue;
final path = p.normalize(entity.path);
Copy link

Choose a reason for hiding this comment

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

When normalizing paths for comparison, ensure consistent case sensitivity handling, especially on Android vs iOS. p.normalize does not change case.

Suggested change
final path = p.normalize(entity.path);
final path = p.normalize(entity.path).toLowerCase();

WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 3), () async {
try {
final dataSource = getIt<MediaFileLocalDataSource>();
Copy link

Choose a reason for hiding this comment

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

Directly injecting a data source into the UI layer violates clean architecture. This logic should ideally be triggered from a Repository or a dedicated Service Class managed by the DI container.

Suggested change
final dataSource = getIt<MediaFileLocalDataSource>();
// Trigger this via a MediaRepository or specific CleanupService
await getIt<MediaRepository>().cleanupOrphanedFiles();

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.


void _scheduleOrphanedMediaCleanup() {
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 3), () async {
Copy link

Choose a reason for hiding this comment

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

Using a hardcoded 3-second delay is fragile. The cleanup should ideally be triggered based on a specific app lifecycle event or after the primary database initialization is confirmed.

Suggested change
Future.delayed(const Duration(seconds: 3), () async {
// Consider moving this to a post-initialization logic block
Future.microtask(() async {

// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdk = 23
minSdkVersion = flutter.minSdkVersion
Copy link

Choose a reason for hiding this comment

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

The property name was updated from minSdk to minSdkVersion. While both usually work in newer Gradle versions, minSdk is the modern namespace-compatible property for the defaultConfig block. Reverting to minSdk is recommended unless there is a specific plugin requirement for the long-form name.

Suggested change
minSdkVersion = flutter.minSdkVersion
minSdk = flutter.minSdkVersion

lastName: lastNameController.text,
username: usernameController.text,
phone: _phoneNumber ?? "",
phone: '', // Phone input commented out
Copy link

Choose a reason for hiding this comment

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

The 'phone' parameter is explicitly set to an empty string. If phone registration is being deprecated or hidden, consider making the parameter optional in the Cubit or handling the nullability there instead of hardcoding empty strings in the UI layer.

Suggested change
phone: '', // Phone input commented out
phone: null, // Explicitly null if not provided

Copy link
Contributor

@nfebe nfebe left a comment

Choose a reason for hiding this comment

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

Check conflicts and bot comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants