react-native-kookit 0.2.1 → 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 +322 -0
- package/android/src/main/java/expo/modules/kookit/FtpClient.kt +399 -0
- package/android/src/main/java/expo/modules/kookit/ReactNativeKookitModule.kt +163 -1
- package/build/ReactNativeKookit.types.d.ts +30 -0
- package/build/ReactNativeKookit.types.d.ts.map +1 -1
- package/build/ReactNativeKookit.types.js.map +1 -1
- package/build/ReactNativeKookitModule.d.ts +56 -1
- package/build/ReactNativeKookitModule.d.ts.map +1 -1
- package/build/ReactNativeKookitModule.js.map +1 -1
- package/build/ReactNativeKookitModule.web.d.ts +10 -1
- package/build/ReactNativeKookitModule.web.d.ts.map +1 -1
- package/build/ReactNativeKookitModule.web.js +28 -0
- package/build/ReactNativeKookitModule.web.js.map +1 -1
- package/ios/ReactNativeKookit.podspec +3 -0
- package/ios/ReactNativeKookitModule.swift +641 -3
- package/package.json +5 -2
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
|
+
}
|