react-native-kookit 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FTP_README.md ADDED
@@ -0,0 +1,322 @@
1
+ # React Native Kookit - FTP Client
2
+
3
+ A React Native Expo module that provides volume key interception and FTP client functionality for iOS and Android.
4
+
5
+ ## Features
6
+
7
+ ### Volume Key Interception
8
+
9
+ - Intercept volume up/down button presses
10
+ - Works on both iOS and Android
11
+ - Prevent default volume behavior (optional)
12
+
13
+ ### FTP Client
14
+
15
+ - Connect to FTP servers
16
+ - List files and directories
17
+ - Download files from FTP server
18
+ - Upload files to FTP server
19
+ - Delete files and directories
20
+ - Create directories
21
+ - Navigate directories
22
+ - Progress monitoring for uploads/downloads
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install react-native-kookit
28
+ ```
29
+
30
+ ### iOS Setup
31
+
32
+ For iOS, no additional setup is required. The module works out of the box.
33
+
34
+ ### Android Setup
35
+
36
+ For Android, your MainActivity must implement the `VolumeKeyInterceptActivity` interface:
37
+
38
+ ```kotlin
39
+ import expo.modules.kookit.VolumeKeyInterceptActivity
40
+
41
+ class MainActivity : ReactActivity(), VolumeKeyInterceptActivity {
42
+ // ... existing code ...
43
+ }
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Basic Import
49
+
50
+ ```typescript
51
+ import ReactNativeKookitModule, {
52
+ FtpConnectionConfig,
53
+ FtpFileInfo,
54
+ FtpProgressInfo,
55
+ } from "react-native-kookit";
56
+ ```
57
+
58
+ ### Volume Key Interception
59
+
60
+ ```typescript
61
+ import { useEffect } from "react";
62
+
63
+ useEffect(() => {
64
+ // Set up volume key listener
65
+ const subscription = ReactNativeKookitModule.addListener(
66
+ "onVolumeButtonPressed",
67
+ (event) => {
68
+ console.log("Volume button pressed:", event.key); // 'up' or 'down'
69
+ }
70
+ );
71
+
72
+ // Enable volume key interception
73
+ ReactNativeKookitModule.enableVolumeKeyInterception();
74
+
75
+ return () => {
76
+ // Clean up
77
+ ReactNativeKookitModule.disableVolumeKeyInterception();
78
+ subscription.remove();
79
+ };
80
+ }, []);
81
+ ```
82
+
83
+ ### FTP Client Usage
84
+
85
+ #### 1. Connect to FTP Server
86
+
87
+ ```typescript
88
+ const config: FtpConnectionConfig = {
89
+ host: "ftp.example.com",
90
+ port: 21, // optional, defaults to 21
91
+ username: "your-username",
92
+ password: "your-password",
93
+ passive: true, // optional, defaults to true
94
+ timeout: 30000, // optional, defaults to 30000ms
95
+ };
96
+
97
+ try {
98
+ await ReactNativeKookitModule.ftpConnect(config);
99
+ console.log("Connected to FTP server");
100
+ } catch (error) {
101
+ console.error("Failed to connect:", error);
102
+ }
103
+ ```
104
+
105
+ #### 2. List Files and Directories
106
+
107
+ ```typescript
108
+ try {
109
+ const files: FtpFileInfo[] = await ReactNativeKookitModule.ftpList();
110
+ files.forEach((file) => {
111
+ console.log(
112
+ `${file.isDirectory ? "DIR" : "FILE"}: ${file.name} (${file.size} bytes)`
113
+ );
114
+ });
115
+ } catch (error) {
116
+ console.error("Failed to list files:", error);
117
+ }
118
+ ```
119
+
120
+ #### 3. Download Files
121
+
122
+ ```typescript
123
+ try {
124
+ const remotePath = "remote-file.txt";
125
+ const localPath = "/path/to/local/file.txt";
126
+
127
+ await ReactNativeKookitModule.ftpDownload(remotePath, localPath);
128
+ console.log("File downloaded successfully");
129
+ } catch (error) {
130
+ console.error("Download failed:", error);
131
+ }
132
+ ```
133
+
134
+ #### 4. Upload Files
135
+
136
+ ```typescript
137
+ try {
138
+ const localPath = "/path/to/local/file.txt";
139
+ const remotePath = "remote-file.txt";
140
+
141
+ await ReactNativeKookitModule.ftpUpload(localPath, remotePath);
142
+ console.log("File uploaded successfully");
143
+ } catch (error) {
144
+ console.error("Upload failed:", error);
145
+ }
146
+ ```
147
+
148
+ #### 5. Progress Monitoring
149
+
150
+ ```typescript
151
+ useEffect(() => {
152
+ const progressListener = ReactNativeKookitModule.addListener(
153
+ "onFtpProgress",
154
+ (progress: FtpProgressInfo) => {
155
+ console.log(
156
+ `Progress: ${progress.percentage}% (${progress.transferred}/${progress.total} bytes)`
157
+ );
158
+ }
159
+ );
160
+
161
+ const completeListener = ReactNativeKookitModule.addListener(
162
+ "onFtpComplete",
163
+ () => {
164
+ console.log("Operation completed");
165
+ }
166
+ );
167
+
168
+ const errorListener = ReactNativeKookitModule.addListener(
169
+ "onFtpError",
170
+ (error) => {
171
+ console.error("FTP Error:", error.error);
172
+ }
173
+ );
174
+
175
+ return () => {
176
+ progressListener.remove();
177
+ completeListener.remove();
178
+ errorListener.remove();
179
+ };
180
+ }, []);
181
+ ```
182
+
183
+ #### 6. Directory Operations
184
+
185
+ ```typescript
186
+ // Create directory
187
+ await ReactNativeKookitModule.ftpCreateDirectory("new-directory");
188
+
189
+ // Change directory
190
+ await ReactNativeKookitModule.ftpChangeDirectory("some-directory");
191
+
192
+ // Get current directory
193
+ const currentDir = await ReactNativeKookitModule.ftpGetCurrentDirectory();
194
+ console.log("Current directory:", currentDir);
195
+
196
+ // Delete file or directory
197
+ await ReactNativeKookitModule.ftpDelete("file.txt", false); // false for file
198
+ await ReactNativeKookitModule.ftpDelete("directory", true); // true for directory
199
+ ```
200
+
201
+ #### 7. Disconnect
202
+
203
+ ```typescript
204
+ try {
205
+ await ReactNativeKookitModule.ftpDisconnect();
206
+ console.log("Disconnected from FTP server");
207
+ } catch (error) {
208
+ console.error("Failed to disconnect:", error);
209
+ }
210
+ ```
211
+
212
+ ## API Reference
213
+
214
+ ### Types
215
+
216
+ ```typescript
217
+ interface FtpConnectionConfig {
218
+ host: string;
219
+ port?: number;
220
+ username: string;
221
+ password: string;
222
+ passive?: boolean;
223
+ timeout?: number;
224
+ }
225
+
226
+ interface FtpFileInfo {
227
+ name: string;
228
+ isDirectory: boolean;
229
+ size: number;
230
+ lastModified: string;
231
+ permissions?: string;
232
+ }
233
+
234
+ interface FtpProgressInfo {
235
+ transferred: number;
236
+ total: number;
237
+ percentage: number;
238
+ }
239
+ ```
240
+
241
+ ### Methods
242
+
243
+ #### Volume Key Methods
244
+
245
+ - `enableVolumeKeyInterception()`: Enable volume key interception
246
+ - `disableVolumeKeyInterception()`: Disable volume key interception
247
+
248
+ #### FTP Methods
249
+
250
+ - `ftpConnect(config: FtpConnectionConfig): Promise<void>`
251
+ - `ftpDisconnect(): Promise<void>`
252
+ - `ftpList(path?: string): Promise<FtpFileInfo[]>`
253
+ - `ftpDownload(remotePath: string, localPath: string): Promise<void>`
254
+ - `ftpUpload(localPath: string, remotePath: string): Promise<void>`
255
+ - `ftpDelete(remotePath: string, isDirectory?: boolean): Promise<void>`
256
+ - `ftpCreateDirectory(remotePath: string): Promise<void>`
257
+ - `ftpChangeDirectory(remotePath: string): Promise<void>`
258
+ - `ftpGetCurrentDirectory(): Promise<string>`
259
+
260
+ ### Events
261
+
262
+ #### Volume Key Events
263
+
264
+ - `onVolumeButtonPressed`: Fired when volume button is pressed
265
+ ```typescript
266
+ {
267
+ key: "up" | "down";
268
+ }
269
+ ```
270
+
271
+ #### FTP Events
272
+
273
+ - `onFtpProgress`: Fired during file transfer operations
274
+
275
+ ```typescript
276
+ { transferred: number, total: number, percentage: number }
277
+ ```
278
+
279
+ - `onFtpComplete`: Fired when an operation completes successfully
280
+
281
+ - `onFtpError`: Fired when an error occurs
282
+ ```typescript
283
+ {
284
+ error: string;
285
+ }
286
+ ```
287
+
288
+ ## Platform Support
289
+
290
+ - ✅ iOS 15.1+
291
+ - ✅ Android API 21+
292
+ - ❌ Web (FTP operations are not supported due to browser security restrictions)
293
+
294
+ ## Example
295
+
296
+ See the [FtpExample.tsx](./example/FtpExample.tsx) file for a complete working example.
297
+
298
+ ## Security Considerations
299
+
300
+ - FTP credentials are transmitted in plain text. Use FTPS or SFTP for secure file transfers when possible.
301
+ - Ensure proper file path validation to prevent directory traversal attacks.
302
+ - Consider implementing connection pooling and timeout handling for production use.
303
+
304
+ ## Troubleshooting
305
+
306
+ ### Android
307
+
308
+ - Make sure your MainActivity implements `VolumeKeyInterceptActivity`
309
+ - Check that you have INTERNET permission in your AndroidManifest.xml
310
+
311
+ ### iOS
312
+
313
+ - Ensure your app has network permissions
314
+ - For file operations, make sure your app has appropriate file system permissions
315
+
316
+ ## Contributing
317
+
318
+ Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
319
+
320
+ ## License
321
+
322
+ MIT
@@ -0,0 +1,399 @@
1
+ package expo.modules.kookit
2
+
3
+ import kotlinx.coroutines.*
4
+ import java.io.*
5
+ import java.net.*
6
+ import java.nio.charset.StandardCharsets
7
+ import java.text.SimpleDateFormat
8
+ import java.util.*
9
+ import java.util.regex.Pattern
10
+
11
+ data class FtpConnectionConfig(
12
+ val host: String,
13
+ val port: Int = 21,
14
+ val username: String,
15
+ val password: String,
16
+ val passive: Boolean = true,
17
+ val timeout: Int = 30000
18
+ )
19
+
20
+ data class FtpFileInfo(
21
+ val name: String,
22
+ val isDirectory: Boolean,
23
+ val size: Long,
24
+ val lastModified: String,
25
+ val permissions: String? = null
26
+ )
27
+
28
+ interface FtpProgressListener {
29
+ fun onProgress(transferred: Long, total: Long)
30
+ fun onComplete()
31
+ fun onError(error: String)
32
+ }
33
+
34
+ class FtpClient {
35
+ private var controlSocket: Socket? = null
36
+ private var controlInput: BufferedReader? = null
37
+ private var controlOutput: PrintWriter? = null
38
+ private var isConnected = false
39
+ private var config: FtpConnectionConfig? = null
40
+ private var useUtf8 = false
41
+
42
+ @Throws(Exception::class)
43
+ suspend fun connect(config: FtpConnectionConfig) = withContext(Dispatchers.IO) {
44
+ this@FtpClient.config = config
45
+
46
+ try {
47
+ // Create control connection
48
+ controlSocket = Socket().apply {
49
+ soTimeout = config.timeout
50
+ connect(InetSocketAddress(config.host, config.port), config.timeout)
51
+ }
52
+
53
+ // Use default charset for initial communication
54
+ controlInput = BufferedReader(InputStreamReader(controlSocket!!.getInputStream(), StandardCharsets.ISO_8859_1))
55
+ controlOutput = PrintWriter(OutputStreamWriter(controlSocket!!.getOutputStream(), StandardCharsets.ISO_8859_1), true)
56
+
57
+ // Read welcome message
58
+ val welcomeResponse = readResponse()
59
+ if (!welcomeResponse.startsWith("220")) {
60
+ throw Exception("FTP server not ready: $welcomeResponse")
61
+ }
62
+
63
+ // Try to switch to UTF-8
64
+ sendCommand("OPTS UTF8 ON")
65
+ val utf8Response = readResponse()
66
+ if (utf8Response.startsWith("200")) {
67
+ useUtf8 = true
68
+ // Re-initialize streams with UTF-8
69
+ controlInput = BufferedReader(InputStreamReader(controlSocket!!.getInputStream(), StandardCharsets.UTF_8))
70
+ controlOutput = PrintWriter(OutputStreamWriter(controlSocket!!.getOutputStream(), StandardCharsets.UTF_8), true)
71
+ }
72
+
73
+ // Send username
74
+ sendCommand("USER ${config.username}")
75
+ val userResponse = readResponse()
76
+ if (!userResponse.startsWith("331") && !userResponse.startsWith("230")) {
77
+ throw Exception("Username rejected: $userResponse")
78
+ }
79
+
80
+ // Send password if needed
81
+ if (userResponse.startsWith("331")) {
82
+ sendCommand("PASS ${config.password}")
83
+ val passResponse = readResponse()
84
+ if (!passResponse.startsWith("230")) {
85
+ throw Exception("Authentication failed: $passResponse")
86
+ }
87
+ }
88
+
89
+ // Set binary mode
90
+ sendCommand("TYPE I")
91
+ val typeResponse = readResponse()
92
+ if (!typeResponse.startsWith("200")) {
93
+ throw Exception("Failed to set binary mode: $typeResponse")
94
+ }
95
+
96
+ isConnected = true
97
+ } catch (e: Exception) {
98
+ disconnect()
99
+ throw e
100
+ }
101
+ }
102
+
103
+ suspend fun disconnect() = withContext(Dispatchers.IO) {
104
+ try {
105
+ if (isConnected) {
106
+ sendCommand("QUIT")
107
+ readResponse()
108
+ }
109
+ } catch (e: Exception) {
110
+ // Ignore errors during disconnect
111
+ } finally {
112
+ try {
113
+ controlInput?.close()
114
+ controlOutput?.close()
115
+ controlSocket?.close()
116
+ } catch (e: Exception) {
117
+ // Ignore
118
+ }
119
+ controlInput = null
120
+ controlOutput = null
121
+ controlSocket = null
122
+ isConnected = false
123
+ useUtf8 = false
124
+ }
125
+ }
126
+
127
+ @Throws(Exception::class)
128
+ suspend fun listFiles(path: String? = null): List<FtpFileInfo> = withContext(Dispatchers.IO) {
129
+ if (!isConnected) throw Exception("Not connected to FTP server")
130
+
131
+ val dataSocket = enterPassiveMode()
132
+
133
+ try {
134
+ val command = if (path != null) "LIST $path" else "LIST"
135
+ sendCommand(command)
136
+ val response = readResponse()
137
+ if (!response.startsWith("150") && !response.startsWith("125")) {
138
+ throw Exception("Failed to list files: $response")
139
+ }
140
+
141
+ val files = mutableListOf<FtpFileInfo>()
142
+ val charset = if (useUtf8) StandardCharsets.UTF_8 else Charsets.ISO_8859_1
143
+ val reader = BufferedReader(InputStreamReader(dataSocket.getInputStream(), charset))
144
+
145
+ reader.useLines { lines ->
146
+ lines.forEach { line ->
147
+ parseListing(line)?.let { fileInfo ->
148
+ files.add(fileInfo)
149
+ }
150
+ }
151
+ }
152
+
153
+ dataSocket.close()
154
+
155
+ val finalResponse = readResponse()
156
+ if (!finalResponse.startsWith("226")) {
157
+ // Some servers send 250, so we'll accept that too.
158
+ if (!finalResponse.startsWith("250")) {
159
+ throw Exception("Failed to complete file listing: $finalResponse")
160
+ }
161
+ }
162
+
163
+ files
164
+ } catch (e: Exception) {
165
+ dataSocket.close()
166
+ throw e
167
+ }
168
+ }
169
+
170
+ @Throws(Exception::class)
171
+ suspend fun downloadFile(remotePath: String, localPath: String, listener: FtpProgressListener? = null) = withContext(Dispatchers.IO) {
172
+ if (!isConnected) throw Exception("Not connected to FTP server")
173
+
174
+ val dataSocket = enterPassiveMode()
175
+ val localFile = File(localPath)
176
+
177
+ // Create parent directories if they don't exist
178
+ localFile.parentFile?.mkdirs()
179
+
180
+ try {
181
+ sendCommand("RETR $remotePath")
182
+ val response = readResponse()
183
+ if (!response.startsWith("150") && !response.startsWith("125")) {
184
+ throw Exception("Failed to start download: $response")
185
+ }
186
+
187
+ val inputStream = dataSocket.getInputStream()
188
+ val outputStream = FileOutputStream(localFile)
189
+
190
+ val buffer = ByteArray(8192)
191
+ var totalBytes = 0L
192
+ var bytesRead: Int
193
+
194
+ try {
195
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
196
+ outputStream.write(buffer, 0, bytesRead)
197
+ totalBytes += bytesRead
198
+ listener?.onProgress(totalBytes, -1) // Size unknown during download
199
+ }
200
+
201
+ outputStream.flush()
202
+ listener?.onComplete()
203
+ } finally {
204
+ outputStream.close()
205
+ inputStream.close()
206
+ }
207
+
208
+ dataSocket.close()
209
+
210
+ val finalResponse = readResponse()
211
+ if (!finalResponse.startsWith("226")) {
212
+ throw Exception("Download failed: $finalResponse")
213
+ }
214
+ } catch (e: Exception) {
215
+ dataSocket.close()
216
+ localFile.delete() // Clean up incomplete file
217
+ listener?.onError(e.message ?: "Download failed")
218
+ throw e
219
+ }
220
+ }
221
+
222
+ @Throws(Exception::class)
223
+ suspend fun uploadFile(localPath: String, remotePath: String, listener: FtpProgressListener? = null) = withContext(Dispatchers.IO) {
224
+ if (!isConnected) throw Exception("Not connected to FTP server")
225
+
226
+ val localFile = File(localPath)
227
+ if (!localFile.exists()) {
228
+ throw Exception("Local file does not exist: $localPath")
229
+ }
230
+
231
+ val dataSocket = enterPassiveMode()
232
+ val fileSize = localFile.length()
233
+
234
+ try {
235
+ sendCommand("STOR $remotePath")
236
+ val response = readResponse()
237
+ if (!response.startsWith("150") && !response.startsWith("125")) {
238
+ throw Exception("Failed to start upload: $response")
239
+ }
240
+
241
+ val inputStream = FileInputStream(localFile)
242
+ val outputStream = dataSocket.getOutputStream()
243
+
244
+ val buffer = ByteArray(8192)
245
+ var totalBytes = 0L
246
+ var bytesRead: Int
247
+
248
+ try {
249
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
250
+ outputStream.write(buffer, 0, bytesRead)
251
+ totalBytes += bytesRead
252
+ listener?.onProgress(totalBytes, fileSize)
253
+ }
254
+
255
+ outputStream.flush()
256
+ listener?.onComplete()
257
+ } finally {
258
+ inputStream.close()
259
+ outputStream.close()
260
+ }
261
+
262
+ dataSocket.close()
263
+
264
+ val finalResponse = readResponse()
265
+ if (!finalResponse.startsWith("226")) {
266
+ throw Exception("Upload failed: $finalResponse")
267
+ }
268
+ } catch (e: Exception) {
269
+ dataSocket.close()
270
+ listener?.onError(e.message ?: "Upload failed")
271
+ throw e
272
+ }
273
+ }
274
+
275
+ @Throws(Exception::class)
276
+ suspend fun deleteFile(remotePath: String, isDirectory: Boolean = false) = withContext(Dispatchers.IO) {
277
+ if (!isConnected) throw Exception("Not connected to FTP server")
278
+
279
+ val command = if (isDirectory) "RMD $remotePath" else "DELE $remotePath"
280
+ sendCommand(command)
281
+ val response = readResponse()
282
+ if (!response.startsWith("250")) {
283
+ throw Exception("Failed to delete: $response")
284
+ }
285
+ }
286
+
287
+ @Throws(Exception::class)
288
+ suspend fun createDirectory(remotePath: String) = withContext(Dispatchers.IO) {
289
+ if (!isConnected) throw Exception("Not connected to FTP server")
290
+
291
+ sendCommand("MKD $remotePath")
292
+ val response = readResponse()
293
+ if (!response.startsWith("257")) {
294
+ throw Exception("Failed to create directory: $response")
295
+ }
296
+ }
297
+
298
+ @Throws(Exception::class)
299
+ suspend fun changeDirectory(remotePath: String) = withContext(Dispatchers.IO) {
300
+ if (!isConnected) throw Exception("Not connected to FTP server")
301
+
302
+ sendCommand("CWD $remotePath")
303
+ val response = readResponse()
304
+ if (!response.startsWith("250")) {
305
+ throw Exception("Failed to change directory: $response")
306
+ }
307
+ }
308
+
309
+ @Throws(Exception::class)
310
+ suspend fun getCurrentDirectory(): String = withContext(Dispatchers.IO) {
311
+ if (!isConnected) throw Exception("Not connected to FTP server")
312
+
313
+ sendCommand("PWD")
314
+ val response = readResponse()
315
+ if (!response.startsWith("257")) {
316
+ throw Exception("Failed to get current directory: $response")
317
+ }
318
+
319
+ // Extract directory from response like: 257 "/home/user" is current directory
320
+ val start = response.indexOf('"')
321
+ val end = response.indexOf('"', start + 1)
322
+ if (start != -1 && end != -1) {
323
+ response.substring(start + 1, end)
324
+ } else {
325
+ throw Exception("Failed to parse current directory response: $response")
326
+ }
327
+ }
328
+
329
+ private fun sendCommand(command: String) {
330
+ controlOutput?.println(command)
331
+ }
332
+
333
+ private fun readResponse(): String {
334
+ val response = StringBuilder()
335
+ var line: String?
336
+
337
+ do {
338
+ line = controlInput?.readLine()
339
+ if (line != null) {
340
+ response.append(line).append("\n")
341
+ }
342
+ } while (line != null && (line.length < 4 || line[3] == '-'))
343
+
344
+ return response.toString().trim()
345
+ }
346
+
347
+ @Throws(Exception::class)
348
+ private fun enterPassiveMode(): Socket {
349
+ sendCommand("PASV")
350
+ val response = readResponse()
351
+ if (!response.startsWith("227")) {
352
+ throw Exception("Failed to enter passive mode: $response")
353
+ }
354
+
355
+ // Parse passive mode response: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
356
+ val pattern = Pattern.compile("\\((\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+)\\)")
357
+ val matcher = pattern.matcher(response)
358
+ if (!matcher.find()) {
359
+ throw Exception("Failed to parse passive mode response: $response")
360
+ }
361
+
362
+ val host = "${matcher.group(1)}.${matcher.group(2)}.${matcher.group(3)}.${matcher.group(4)}"
363
+ val port = matcher.group(5)!!.toInt() * 256 + matcher.group(6)!!.toInt()
364
+
365
+ return Socket().apply {
366
+ soTimeout = config?.timeout ?: 30000
367
+ connect(InetSocketAddress(host, port), config?.timeout ?: 30000)
368
+ }
369
+ }
370
+
371
+ private fun parseListing(line: String): FtpFileInfo? {
372
+ if (line.trim().isEmpty()) return null
373
+
374
+ // Parse Unix-style listing: drwxr-xr-x 3 user group 4096 Mar 15 10:30 dirname
375
+ val parts = line.trim().split("\\s+".toRegex())
376
+ if (parts.size < 9) return null
377
+
378
+ val permissions = parts[0]
379
+ val isDirectory = permissions.startsWith("d")
380
+ val size = if (isDirectory) 0L else parts[4].toLongOrNull() ?: 0L
381
+
382
+ // Reconstruct filename (can contain spaces)
383
+ val name = parts.drop(8).joinToString(" ")
384
+
385
+ // Parse date
386
+ val month = parts[5]
387
+ val day = parts[6]
388
+ val yearOrTime = parts[7]
389
+ val lastModified = "$month $day $yearOrTime"
390
+
391
+ return FtpFileInfo(
392
+ name = name,
393
+ isDirectory = isDirectory,
394
+ size = size,
395
+ lastModified = lastModified,
396
+ permissions = permissions
397
+ )
398
+ }
399
+ }