Compare commits

...

2 Commits

Author SHA1 Message Date
3357fe76d4 Feat: 데이터 포트 범위 지정 기능 추가
방화벽 환경에서 특정 포트 범위만 개방하여 사용할 수 있도록 데이터 포트 범위 설정 기능 추가.
- FTPConfig에 minDataPort, maxDataPort 설정 추가 (기본값: 50000-50100)
- FTPDataConnection에서 지정된 범위 내 사용 가능한 포트 자동 할당
- MainActivity UI에 데이터 포트 범위 설정 필드 추가
- 포트 범위 유효성 검증 (최소 10개 포트, 1024-65535 범위)
- 범위 미지정 시 기존처럼 랜덤 포트 사용

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-02 04:33:53 +09:00
12f27e9f13 Fix: 앱 재시작 시 UI 상태와 서비스 상태 동기화 문제 해결
onResume()에서 실제 FTPService 실행 상태를 확인하여 UI를 동기화함으로써 화면 전환 또는 앱 재시작 시에도 올바른 버튼 상태가 표시되도록 수정.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-02 02:10:39 +09:00
7 changed files with 225 additions and 18 deletions

View File

@@ -9,8 +9,12 @@ public class FTPConfig {
private static final String KEY_PORT = "ftp_port"; private static final String KEY_PORT = "ftp_port";
private static final String KEY_ROOT_DIR_URI = "root_directory_uri"; private static final String KEY_ROOT_DIR_URI = "root_directory_uri";
private static final String KEY_ROOT_DIR_PATH = "root_directory_path"; private static final String KEY_ROOT_DIR_PATH = "root_directory_path";
private static final String KEY_MIN_DATA_PORT = "min_data_port";
private static final String KEY_MAX_DATA_PORT = "max_data_port";
private static final int DEFAULT_PORT = 2121; private static final int DEFAULT_PORT = 2121;
private static final int DEFAULT_MIN_DATA_PORT = 50000;
private static final int DEFAULT_MAX_DATA_PORT = 50100;
private final SharedPreferences preferences; private final SharedPreferences preferences;
@@ -60,4 +64,27 @@ public class FTPConfig {
.putString(KEY_ROOT_DIR_PATH, rootDirPath) .putString(KEY_ROOT_DIR_PATH, rootDirPath)
.apply(); .apply();
} }
public int getMinDataPort() {
return preferences.getInt(KEY_MIN_DATA_PORT, DEFAULT_MIN_DATA_PORT);
}
public void setMinDataPort(int port) {
preferences.edit().putInt(KEY_MIN_DATA_PORT, port).apply();
}
public int getMaxDataPort() {
return preferences.getInt(KEY_MAX_DATA_PORT, DEFAULT_MAX_DATA_PORT);
}
public void setMaxDataPort(int port) {
preferences.edit().putInt(KEY_MAX_DATA_PORT, port).apply();
}
public void setDataPortRange(int minPort, int maxPort) {
preferences.edit()
.putInt(KEY_MIN_DATA_PORT, minPort)
.putInt(KEY_MAX_DATA_PORT, maxPort)
.apply();
}
} }

View File

@@ -20,14 +20,41 @@ public class FTPDataConnection {
private int passivePort; private int passivePort;
public boolean openPassiveMode(InetAddress bindAddress) { public boolean openPassiveMode(InetAddress bindAddress) {
try { return openPassiveMode(bindAddress, 0, 0);
// Use port 0 to get a random available port }
passiveSocket = new ServerSocket(0, 1, bindAddress);
passiveSocket.setSoTimeout(DATA_CONNECTION_TIMEOUT);
passivePort = passiveSocket.getLocalPort();
Log.i(TAG, "Passive mode enabled on port: " + passivePort); public boolean openPassiveMode(InetAddress bindAddress, int minPort, int maxPort) {
return true; try {
if (minPort <= 0 || maxPort <= 0 || minPort > maxPort) {
// Use port 0 to get a random available port
passiveSocket = new ServerSocket(0, 1, bindAddress);
passiveSocket.setSoTimeout(DATA_CONNECTION_TIMEOUT);
passivePort = passiveSocket.getLocalPort();
Log.i(TAG, "Passive mode enabled on random port: " + passivePort);
return true;
}
// Try to find an available port in the specified range
IOException lastException = null;
for (int port = minPort; port <= maxPort; port++) {
try {
passiveSocket = new ServerSocket(port, 1, bindAddress);
passiveSocket.setSoTimeout(DATA_CONNECTION_TIMEOUT);
passivePort = passiveSocket.getLocalPort();
Log.i(TAG, "Passive mode enabled on port: " + passivePort + " (range: " + minPort + "-" + maxPort + ")");
return true;
} catch (IOException e) {
lastException = e;
// Port is in use, try next port
}
}
// No available port found in range
Log.e(TAG, "No available port in range " + minPort + "-" + maxPort);
if (lastException != null) {
Log.e(TAG, "Last error: " + lastException.getMessage());
}
return false;
} catch (IOException e) { } catch (IOException e) {
Log.e(TAG, "Error opening passive mode: " + e.getMessage()); Log.e(TAG, "Error opening passive mode: " + e.getMessage());
return false; return false;

View File

@@ -23,19 +23,27 @@ public class FTPServer {
private int port; private int port;
private Context context; private Context context;
private Uri rootDirectoryUri; private Uri rootDirectoryUri;
private int minDataPort = 0;
private int maxDataPort = 0;
public FTPServer(Context context) { public FTPServer(Context context) {
this(context, DEFAULT_PORT, null); this(context, DEFAULT_PORT, null, 0, 0);
} }
public FTPServer(Context context, int port) { public FTPServer(Context context, int port) {
this(context, port, null); this(context, port, null, 0, 0);
} }
public FTPServer(Context context, int port, Uri rootDirectoryUri) { public FTPServer(Context context, int port, Uri rootDirectoryUri) {
this(context, port, rootDirectoryUri, 0, 0);
}
public FTPServer(Context context, int port, Uri rootDirectoryUri, int minDataPort, int maxDataPort) {
this.context = context; this.context = context;
this.port = port; this.port = port;
this.rootDirectoryUri = rootDirectoryUri; this.rootDirectoryUri = rootDirectoryUri;
this.minDataPort = minDataPort;
this.maxDataPort = maxDataPort;
this.executorService = Executors.newFixedThreadPool(MAX_CONNECTIONS); this.executorService = Executors.newFixedThreadPool(MAX_CONNECTIONS);
} }
@@ -56,7 +64,7 @@ public class FTPServer {
Socket clientSocket = serverSocket.accept(); Socket clientSocket = serverSocket.accept();
Log.i(TAG, "New client connection from: " + clientSocket.getInetAddress()); Log.i(TAG, "New client connection from: " + clientSocket.getInetAddress());
FTPSession session = new FTPSession(clientSocket, context, rootDirectoryUri); FTPSession session = new FTPSession(clientSocket, context, rootDirectoryUri, minDataPort, maxDataPort);
executorService.execute(session); executorService.execute(session);
} catch (IOException e) { } catch (IOException e) {

View File

@@ -37,11 +37,13 @@ public class FTPService extends Service {
if (ACTION_START.equals(action)) { if (ACTION_START.equals(action)) {
int port = intent.getIntExtra("port", 2121); int port = intent.getIntExtra("port", 2121);
String rootDirUriString = intent.getStringExtra("rootDirUri"); String rootDirUriString = intent.getStringExtra("rootDirUri");
int minDataPort = intent.getIntExtra("minDataPort", 0);
int maxDataPort = intent.getIntExtra("maxDataPort", 0);
android.net.Uri rootDirUri = null; android.net.Uri rootDirUri = null;
if (rootDirUriString != null && !rootDirUriString.isEmpty()) { if (rootDirUriString != null && !rootDirUriString.isEmpty()) {
rootDirUri = android.net.Uri.parse(rootDirUriString); rootDirUri = android.net.Uri.parse(rootDirUriString);
} }
startFTPServer(port, rootDirUri); startFTPServer(port, rootDirUri, minDataPort, maxDataPort);
} else if (ACTION_STOP.equals(action)) { } else if (ACTION_STOP.equals(action)) {
stopFTPServer(); stopFTPServer();
} }
@@ -50,19 +52,24 @@ public class FTPService extends Service {
return START_STICKY; return START_STICKY;
} }
private void startFTPServer(int port, android.net.Uri rootDirUri) { private void startFTPServer(int port, android.net.Uri rootDirUri, int minDataPort, int maxDataPort) {
if (ftpServer != null && ftpServer.isRunning()) { if (ftpServer != null && ftpServer.isRunning()) {
Log.w(TAG, "FTP Server is already running"); Log.w(TAG, "FTP Server is already running");
return; return;
} }
ftpServer = new FTPServer(this, port, rootDirUri); ftpServer = new FTPServer(this, port, rootDirUri, minDataPort, maxDataPort);
ftpServer.start(); ftpServer.start();
Notification notification = createNotification("FTP Server is running on port " + ftpServer.getPort()); String notificationText = "FTP Server is running on port " + ftpServer.getPort();
if (minDataPort > 0 && maxDataPort > 0) {
notificationText += " (Data ports: " + minDataPort + "-" + maxDataPort + ")";
}
Notification notification = createNotification(notificationText);
startForeground(NOTIFICATION_ID, notification); startForeground(NOTIFICATION_ID, notification);
Log.i(TAG, "FTP Server started on port " + port + " with root URI: " + rootDirUri); Log.i(TAG, "FTP Server started on port " + port + " with root URI: " + rootDirUri +
", data port range: " + minDataPort + "-" + maxDataPort);
} }
private void stopFTPServer() { private void stopFTPServer() {

View File

@@ -31,15 +31,23 @@ public class FTPSession implements Runnable {
private FTPDataConnection dataConnection; private FTPDataConnection dataConnection;
private String transferType = "A"; // A = ASCII, I = Binary private String transferType = "A"; // A = ASCII, I = Binary
private boolean useUtf8 = true; // UTF-8 enabled by default for better compatibility private boolean useUtf8 = true; // UTF-8 enabled by default for better compatibility
private int minDataPort = 0;
private int maxDataPort = 0;
public FTPSession(Socket socket, Context context) { public FTPSession(Socket socket, Context context) {
this(socket, context, null); this(socket, context, null, 0, 0);
} }
public FTPSession(Socket socket, Context context, Uri rootDirectoryUri) { public FTPSession(Socket socket, Context context, Uri rootDirectoryUri) {
this(socket, context, rootDirectoryUri, 0, 0);
}
public FTPSession(Socket socket, Context context, Uri rootDirectoryUri, int minDataPort, int maxDataPort) {
this.controlSocket = socket; this.controlSocket = socket;
this.fileSystem = new FTPFileSystem(context, rootDirectoryUri); this.fileSystem = new FTPFileSystem(context, rootDirectoryUri);
this.dataConnection = null; this.dataConnection = null;
this.minDataPort = minDataPort;
this.maxDataPort = maxDataPort;
} }
@Override @Override
@@ -405,7 +413,7 @@ public class FTPSession implements Runnable {
// Get server address from control socket // Get server address from control socket
String serverAddress = controlSocket.getLocalAddress().getHostAddress(); String serverAddress = controlSocket.getLocalAddress().getHostAddress();
if (dataConnection.openPassiveMode(controlSocket.getLocalAddress())) { if (dataConnection.openPassiveMode(controlSocket.getLocalAddress(), minDataPort, maxDataPort)) {
int port = dataConnection.getPassivePort(); int port = dataConnection.getPassivePort();
// Format: h1,h2,h3,h4,p1,p2 // Format: h1,h2,h3,h4,p1,p2

View File

@@ -8,6 +8,8 @@ import androidx.core.content.ContextCompat;
import androidx.documentfile.provider.DocumentFile; import androidx.documentfile.provider.DocumentFile;
import android.Manifest; import android.Manifest;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
@@ -33,6 +35,8 @@ public class MainActivity extends AppCompatActivity {
private TextView rootDirPathTextView; private TextView rootDirPathTextView;
private Button selectDirButton; private Button selectDirButton;
private Button saveSettingsButton; private Button saveSettingsButton;
private EditText minDataPortEditText;
private EditText maxDataPortEditText;
private FTPConfig config; private FTPConfig config;
private ActivityResultLauncher<Uri> directoryPickerLauncher; private ActivityResultLauncher<Uri> directoryPickerLauncher;
@@ -89,11 +93,15 @@ public class MainActivity extends AppCompatActivity {
rootDirPathTextView = findViewById(R.id.rootDirPathTextView); rootDirPathTextView = findViewById(R.id.rootDirPathTextView);
selectDirButton = findViewById(R.id.selectDirButton); selectDirButton = findViewById(R.id.selectDirButton);
saveSettingsButton = findViewById(R.id.saveSettingsButton); saveSettingsButton = findViewById(R.id.saveSettingsButton);
minDataPortEditText = findViewById(R.id.minDataPortEditText);
maxDataPortEditText = findViewById(R.id.maxDataPortEditText);
} }
private void loadSettings() { private void loadSettings() {
int port = config.getPort(); int port = config.getPort();
String rootPath = config.getRootDirectoryPath(); String rootPath = config.getRootDirectoryPath();
int minDataPort = config.getMinDataPort();
int maxDataPort = config.getMaxDataPort();
portEditText.setText(String.valueOf(port)); portEditText.setText(String.valueOf(port));
portTextView.setText("Port: " + port); portTextView.setText("Port: " + port);
@@ -103,6 +111,9 @@ public class MainActivity extends AppCompatActivity {
} else { } else {
rootDirPathTextView.setText("Not selected"); rootDirPathTextView.setText("Not selected");
} }
minDataPortEditText.setText(String.valueOf(minDataPort));
maxDataPortEditText.setText(String.valueOf(maxDataPort));
} }
private void setupListeners() { private void setupListeners() {
@@ -140,7 +151,45 @@ public class MainActivity extends AppCompatActivity {
return; return;
} }
// Validate data port range
String minPortStr = minDataPortEditText.getText().toString().trim();
String maxPortStr = maxDataPortEditText.getText().toString().trim();
int minDataPort = 0;
int maxDataPort = 0;
if (!minPortStr.isEmpty() || !maxPortStr.isEmpty()) {
if (minPortStr.isEmpty() || maxPortStr.isEmpty()) {
Toast.makeText(this, "Please enter both min and max data ports or leave both empty", Toast.LENGTH_SHORT).show();
return;
}
try {
minDataPort = Integer.parseInt(minPortStr);
maxDataPort = Integer.parseInt(maxPortStr);
if (minDataPort < 1024 || minDataPort > 65535 || maxDataPort < 1024 || maxDataPort > 65535) {
Toast.makeText(this, "Data ports must be between 1024 and 65535", Toast.LENGTH_SHORT).show();
return;
}
if (minDataPort >= maxDataPort) {
Toast.makeText(this, "Min data port must be less than max data port", Toast.LENGTH_SHORT).show();
return;
}
if (maxDataPort - minDataPort < 10) {
Toast.makeText(this, "Data port range should be at least 10 ports", Toast.LENGTH_SHORT).show();
return;
}
} catch (NumberFormatException e) {
Toast.makeText(this, "Invalid data port number", Toast.LENGTH_SHORT).show();
return;
}
}
config.setPort(port); config.setPort(port);
config.setDataPortRange(minDataPort, maxDataPort);
portTextView.setText("Port: " + port); portTextView.setText("Port: " + port);
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show();
@@ -194,6 +243,8 @@ public class MainActivity extends AppCompatActivity {
serviceIntent.setAction(FTPService.ACTION_START); serviceIntent.setAction(FTPService.ACTION_START);
serviceIntent.putExtra("port", config.getPort()); serviceIntent.putExtra("port", config.getPort());
serviceIntent.putExtra("rootDirUri", rootDirUri); serviceIntent.putExtra("rootDirUri", rootDirUri);
serviceIntent.putExtra("minDataPort", config.getMinDataPort());
serviceIntent.putExtra("maxDataPort", config.getMaxDataPort());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent); startForegroundService(serviceIntent);
@@ -239,4 +290,27 @@ public class MainActivity extends AppCompatActivity {
} }
} }
} }
/**
* Check if FTPService is currently running in the background
*/
private boolean isServiceRunning() {
ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
if (manager != null) {
for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {
if (FTPService.class.getName().equals(service.service.getClassName())) {
return true;
}
}
}
return false;
}
@Override
protected void onResume() {
super.onResume();
// Sync UI with actual service state when activity resumes
isServerRunning = isServiceRunning();
updateUI();
}
} }

View File

@@ -144,6 +144,62 @@
app:layout_constraintTop_toBottomOf="@id/rootDirPathTextView" app:layout_constraintTop_toBottomOf="@id/rootDirPathTextView"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/dataPortRangeLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Data Port Range:"
android:textSize="16sp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/selectDirButton"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/minDataPortLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Min:"
android:textSize="14sp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/dataPortRangeLabel"
app:layout_constraintStart_toStartOf="parent" />
<EditText
android:id="@+id/minDataPortEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="number"
android:hint="50000"
android:layout_marginStart="16dp"
app:layout_constraintBaseline_toBaselineOf="@id/minDataPortLabel"
app:layout_constraintStart_toEndOf="@id/minDataPortLabel"
app:layout_constraintEnd_toStartOf="@id/maxDataPortLabel"
app:layout_constraintWidth_default="percent"
app:layout_constraintHorizontal_weight="1" />
<TextView
android:id="@+id/maxDataPortLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Max:"
android:textSize="14sp"
android:layout_marginStart="16dp"
app:layout_constraintBaseline_toBaselineOf="@id/minDataPortLabel"
app:layout_constraintStart_toEndOf="@id/minDataPortEditText" />
<EditText
android:id="@+id/maxDataPortEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="number"
android:hint="50100"
android:layout_marginStart="16dp"
app:layout_constraintBaseline_toBaselineOf="@id/minDataPortLabel"
app:layout_constraintStart_toEndOf="@id/maxDataPortLabel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_default="percent"
app:layout_constraintHorizontal_weight="1" />
<Button <Button
android:id="@+id/saveSettingsButton" android:id="@+id/saveSettingsButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -151,7 +207,7 @@
android:text="Save Settings" android:text="Save Settings"
android:minWidth="150dp" android:minWidth="150dp"
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
app:layout_constraintTop_toBottomOf="@id/selectDirButton" app:layout_constraintTop_toBottomOf="@id/minDataPortLabel"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />