Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
150 changes: 150 additions & 0 deletions apps/native-component-list/src/screens/FileSystemScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
DownloadPauseState,
DownloadTaskState,
UploadTaskState,
WatchSubscription,
WatchEvent,
} from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as MediaLibrary from 'expo-media-library';
Expand Down Expand Up @@ -72,6 +74,7 @@ export default function FileSystemScreen() {
<FileInfoSection withCurrentFile={withCurrentFile} />
<ReadWriteSection withCurrentFile={withCurrentFile} />
<FileHandleSection currentFile={currentFile} />
<FileWatcherSection currentFile={currentFile} />
<CopyMoveSection
withCurrentFile={withCurrentFile}
safDirectory={safDirectory}
Expand Down Expand Up @@ -440,6 +443,113 @@ function FileHandleSection({ currentFile }: { currentFile: File | null }) {
);
}

function FileWatcherSection({ currentFile }: { currentFile: File | null }) {
const subscriptionRef = useRef<WatchSubscription | null>(null);
const [watchTarget, setWatchTarget] = useState<string | null>(null);
const [watchLog, setWatchLog] = useState<string[]>([]);

useEffect(() => {
return () => {
subscriptionRef.current?.remove();
subscriptionRef.current = null;
};
}, []);

function appendLog(line: string) {
const timestamp = new Date().toLocaleTimeString();
setWatchLog((prev) => [`[${timestamp}] ${line}`, ...prev].slice(0, 50));
}

function stopWatching() {
if (subscriptionRef.current) {
subscriptionRef.current.remove();
subscriptionRef.current = null;
appendLog('Stopped watching');
setWatchTarget(null);
}
}

function watchFile(file: File) {
stopWatching();
try {
const subscription = file.watch((event: WatchEvent<File>) => {
appendLog(`${event.type.toUpperCase()}: ${event.target.name}`);
});
subscriptionRef.current = subscription;
setWatchTarget(`File: ${file.name}`);
appendLog(`Started watching file: ${file.name}`);
} catch (e: any) {
Alert.alert('Error', e.message);
}
}

function watchDirectory(dir: Directory) {
stopWatching();
try {
const subscription = dir.watch((event: WatchEvent<File | Directory>) => {
const targetType = event.target instanceof Directory ? 'dir' : 'file';
appendLog(`${event.type.toUpperCase()} (${targetType}): ${event.target.name}`);
});
subscriptionRef.current = subscription;
setWatchTarget(`Directory: ${dir.name}`);
appendLog(`Started watching directory: ${dir.name}`);
} catch (e: any) {
Alert.alert('Error', e.message);
}
}

return (
<>
<HeadingText>File System Watcher</HeadingText>
<Text style={styles.note}>Watch files or directories for changes</Text>

<ListButton
title="Watch current file"
disabled={!currentFile}
onPress={() => watchFile(currentFile!)}
/>
<ListButton
title="Watch cache directory"
onPress={() => {
const cacheDir = new Directory(Paths.cache, 'test_sandbox');
cacheDir.create({ intermediates: true, idempotent: true });
watchDirectory(cacheDir);
}}
/>
<ListButton title="Watch document directory" onPress={() => watchDirectory(Paths.document)} />
<ListButton
title="Stop watching"
disabled={!subscriptionRef.current}
onPress={stopWatching}
/>

{watchTarget && (
<View style={styles.watchStatusBar}>
<Text style={styles.watchStatusText}>Watching: {watchTarget}</Text>
</View>
)}

{watchLog.length > 0 && (
<>
<View style={styles.watchLogHeader}>
<Text style={styles.watchLogTitle}>Event Log</Text>
<TouchableOpacity onPress={() => setWatchLog([])}>
<Text style={styles.clearLogButton}>Clear</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.watchLogContainer} nestedScrollEnabled>
{watchLog.map((line, index) => (
<Text key={index} style={styles.watchLogLine}>
{line}
</Text>
))}
</ScrollView>
</>
)}
</>
);
}

// ===== Section: Copy & Move =====

function CopyMoveSection({
Expand Down Expand Up @@ -1273,4 +1383,44 @@ const styles = StyleSheet.create({
color: '#888',
marginBottom: 4,
},
watchStatusBar: {
backgroundColor: '#e8f5e9',
padding: 8,
borderRadius: 4,
marginTop: 8,
},
watchStatusText: {
fontSize: 12,
color: '#2e7d32',
fontWeight: '500',
},
watchLogHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
paddingHorizontal: 4,
},
watchLogTitle: {
fontSize: 13,
fontWeight: '600',
color: '#333',
},
clearLogButton: {
fontSize: 12,
color: '#4630eb',
},
watchLogContainer: {
maxHeight: 150,
backgroundColor: '#f5f5f5',
borderRadius: 4,
padding: 8,
marginTop: 4,
},
watchLogLine: {
fontSize: 11,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
color: '#333',
paddingVertical: 2,
},
});
114 changes: 114 additions & 0 deletions apps/test-suite/tests/FileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,28 @@ import { Platform } from 'react-native';
export const name = 'FileSystem';
const shouldSkipTestsRequiringPermissions = true;

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function test({ describe, expect, it, ...t }) {
const describeWithPermissions = shouldSkipTestsRequiringPermissions ? t.xdescribe : describe;

const testDirectory = FS.documentDirectory + 'tests/';
const watcherDirectory = new Directory(Paths.cache, 'watcher-tests');
t.beforeEach(async () => {
try {
await FS.makeDirectoryAsync(testDirectory);
} catch {}
watcherDirectory.create({ idempotent: true, intermediates: true });
});
t.afterEach(async () => {
try {
await FS.deleteAsync(testDirectory);
} catch {}
if (watcherDirectory.exists) {
watcherDirectory.delete();
}
});
describe('FileSystem', () => {
if (Constants.appOwnership === 'expo') {
Expand Down Expand Up @@ -215,6 +224,111 @@ export async function test({ describe, expect, it, ...t }) {
}
});

describe('watching files and directories', () => {
let originalTimeout;

t.beforeAll(() => {
originalTimeout = t.jasmine.DEFAULT_TIMEOUT_INTERVAL;
t.jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout * 4;
});

t.afterAll(() => {
t.jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
});

it('returns an idempotent subscription', () => {
const file = new File(watcherDirectory, 'subscription.txt');
file.create();

const subscription = file.watch(() => {});
subscription.remove();

expect(() => subscription.remove()).not.toThrow();
});

it('emits a modified event when a watched file is written', async () => {
const file = new File(watcherDirectory, 'modified.txt');
file.create();
file.write('before');

const events: { type: string }[] = [];
const subscription = file.watch((event) => events.push(event));

try {
file.write('after');
await delay(300);

expect(events.some((event) => event.type === 'modified')).toBe(true);
} finally {
subscription.remove();
}
});

it('emits a deleted event when a watched file is deleted', async () => {
const file = new File(watcherDirectory, 'deleted.txt');
file.create();

const events: { type: string }[] = [];
const subscription = file.watch((event) => events.push(event));

try {
file.delete();
await delay(300);

expect(events.some((event) => event.type === 'deleted')).toBe(true);
} finally {
subscription.remove();
}
});

it('filters events by requested types', async () => {
const file = new File(watcherDirectory, 'filter.txt');
file.create();
file.write('before');

const events: { type: string }[] = [];
const subscription = file.watch((event) => events.push(event), {
events: ['deleted'],
});

try {
file.write('after');
await delay(300);

expect(events.length).toBe(0);
} finally {
subscription.remove();
}
});

it('emits an event when a child file is created in a watched directory', async () => {
const directory = new Directory(watcherDirectory, 'children');
directory.create();

const events: { type: string; target: File | Directory }[] = [];
const subscription = directory.watch((event) => events.push(event));

try {
const childFile = new File(directory, 'child.txt');
childFile.create();
await delay(300);

expect(events.length).toBeGreaterThan(0);

if (Platform.OS === 'ios') {
expect(events[0]?.type).toBe('modified');
expect(events[0]?.target instanceof Directory).toBe(true);
expect(events[0]?.target.uri).toBe(directory.uri);
} else {
expect(events.some((event) => event.type === 'created')).toBe(true);
expect(events.some((event) => event.target instanceof File)).toBe(true);
}
} finally {
subscription.remove();
}
});
});

if (Platform.OS === 'android') {
describe('Android bundle directory listing', () => {
it('returns correct names for files in bundle directory', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/expo-file-system/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Add `onProgress` callback and `AbortSignal` support to `File.downloadFileAsync()`. ([#43053](https://github.com/expo/expo/pull/43053) by [@aleqsio](https://github.com/aleqsio))
- Add `file.createUploadTask()` and `File.createDownloadTask()` APIs ([#44055](https://github.com/expo/expo/pull/44055) by [@barthap](https://github.com/barthap))
- Add `File.upload()` with legacy-compatible upload semantics, progress callbacks, and `AbortSignal` support. ([#45033](https://github.com/expo/expo/pull/45033) by [@barthap](https://github.com/barthap))
- Add support for watching file/directory events. ([#44986](https://github.com/expo/expo/pull/44986) by [@barthap](https://github.com/barthap))

### 🐛 Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,15 @@ internal class InvalidResumeDataException :

internal class DownloadCancelledException :
CodedException("Download was cancelled")

internal class WatcherSetupException(path: String) :
CodedException("Cannot start watching path '$path'")

internal class WatcherPermissionException(path: String) :
CodedException("No permission to watch path '$path'")

internal class WatcherPathNotFoundException(path: String) :
CodedException("Path does not exist: '$path'")

internal class WatcherUnsupportedPathException(path: String) :
CodedException("Cannot watch path '$path'. Only local file:// paths are supported.")
Original file line number Diff line number Diff line change
Expand Up @@ -386,5 +386,21 @@ class FileSystemModule : Module() {
task.cancel()
}
}

Class(FileSystemWatcher::class) {
Events("change")

Constructor { uri: Uri, options: WatchOptions? ->
FileSystemWatcher(appContext, uri, options)
}

Function("start") { watcher: FileSystemWatcher ->
watcher.start()
}

Function("stop") { watcher: FileSystemWatcher ->
watcher.stop()
}
}
}
}
Loading
Loading