Feat: 사용자 설정 기능 구현 (포트, 루트 디렉토리)

- FTPConfig 클래스 생성: SharedPreferences 기반 설정 관리
  * 포트 번호 저장/로드
  * 루트 디렉토리 URI 및 경로 저장/로드
- MainActivity UI 업데이트
  * ScrollView로 전체 레이아웃 감싸기
  * 설정 섹션 추가 (포트 입력, 디렉토리 선택)
  * Storage Access Framework로 디렉토리 선택 기능
  * 설정 유효성 검사 (포트 범위: 1024-65535)
- MainActivity 로직 업데이트
  * ActivityResultLauncher로 디렉토리 선택
  * takePersistableUriPermission으로 지속적 권한 획득
  * 설정 로드 및 저장 기능
  * 서버 시작 전 루트 디렉토리 필수 체크
- FTPService 수정
  * Intent로 포트 및 루트 디렉토리 전달받음
  * FTPServer에 설정값 전달
- FTPServer 생성자 확장
  * 포트 및 루트 디렉토리 파라미터 추가
  * FTPSession에 설정값 전달
- FTPSession 생성자 확장
  * 루트 디렉토리 파라미터 추가
  * FTPFileSystem에 전달
- FTPFileSystem 생성자 확장
  * 사용자 지정 루트 디렉토리 지원
  * null일 경우 기본 디렉토리 사용
  * 경로 유효성 검증

이제 사용자가 원하는 디렉토리를 FTP 루트로 설정 가능
Android 11+ Scoped Storage 문제 해결

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 04:12:28 +09:00
parent 710ec15fb1
commit 9594233c8e
6 changed files with 285 additions and 11 deletions

View File

@@ -0,0 +1,63 @@
package be.gyu.android.server.ftp;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
public class FTPConfig {
private static final String PREF_NAME = "FTPServerConfig";
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_PATH = "root_directory_path";
private static final int DEFAULT_PORT = 2121;
private final SharedPreferences preferences;
public FTPConfig(Context context) {
this.preferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
}
public int getPort() {
return preferences.getInt(KEY_PORT, DEFAULT_PORT);
}
public void setPort(int port) {
preferences.edit().putInt(KEY_PORT, port).apply();
}
public String getRootDirectoryUri() {
return preferences.getString(KEY_ROOT_DIR_URI, null);
}
public void setRootDirectoryUri(String uriString) {
preferences.edit().putString(KEY_ROOT_DIR_URI, uriString).apply();
}
public String getRootDirectoryPath() {
return preferences.getString(KEY_ROOT_DIR_PATH, null);
}
public void setRootDirectoryPath(String path) {
preferences.edit().putString(KEY_ROOT_DIR_PATH, path).apply();
}
public boolean hasRootDirectory() {
return getRootDirectoryPath() != null && !getRootDirectoryPath().isEmpty();
}
public void clearRootDirectory() {
preferences.edit()
.remove(KEY_ROOT_DIR_URI)
.remove(KEY_ROOT_DIR_PATH)
.apply();
}
public void saveSettings(int port, String rootDirUri, String rootDirPath) {
preferences.edit()
.putInt(KEY_PORT, port)
.putString(KEY_ROOT_DIR_URI, rootDirUri)
.putString(KEY_ROOT_DIR_PATH, rootDirPath)
.apply();
}
}

View File

@@ -19,13 +19,19 @@ public class FTPServer {
private Thread acceptThread; private Thread acceptThread;
private boolean isRunning = false; private boolean isRunning = false;
private int port; private int port;
private String rootDirectory;
public FTPServer() { public FTPServer() {
this(DEFAULT_PORT); this(DEFAULT_PORT, null);
} }
public FTPServer(int port) { public FTPServer(int port) {
this(port, null);
}
public FTPServer(int port, String rootDirectory) {
this.port = port; this.port = port;
this.rootDirectory = rootDirectory;
this.executorService = Executors.newFixedThreadPool(MAX_CONNECTIONS); this.executorService = Executors.newFixedThreadPool(MAX_CONNECTIONS);
} }
@@ -46,7 +52,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); FTPSession session = new FTPSession(clientSocket, rootDirectory);
executorService.execute(session); executorService.execute(session);
} catch (IOException e) { } catch (IOException e) {

View File

@@ -35,7 +35,9 @@ public class FTPService extends Service {
String action = intent.getAction(); String action = intent.getAction();
if (ACTION_START.equals(action)) { if (ACTION_START.equals(action)) {
startFTPServer(); int port = intent.getIntExtra("port", 2121);
String rootDir = intent.getStringExtra("rootDir");
startFTPServer(port, rootDir);
} else if (ACTION_STOP.equals(action)) { } else if (ACTION_STOP.equals(action)) {
stopFTPServer(); stopFTPServer();
} }
@@ -44,19 +46,19 @@ public class FTPService extends Service {
return START_STICKY; return START_STICKY;
} }
private void startFTPServer() { private void startFTPServer(int port, String rootDir) {
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(); ftpServer = new FTPServer(port, rootDir);
ftpServer.start(); ftpServer.start();
Notification notification = createNotification("FTP Server is running on port " + ftpServer.getPort()); Notification notification = createNotification("FTP Server is running on port " + ftpServer.getPort());
startForeground(NOTIFICATION_ID, notification); startForeground(NOTIFICATION_ID, notification);
Log.i(TAG, "FTP Server started"); Log.i(TAG, "FTP Server started on port " + port + " with root: " + rootDir);
} }
private void stopFTPServer() { private void stopFTPServer() {

View File

@@ -24,8 +24,12 @@ public class FTPSession implements Runnable {
private String transferType = "A"; // A = ASCII, I = Binary private String transferType = "A"; // A = ASCII, I = Binary
public FTPSession(Socket socket) { public FTPSession(Socket socket) {
this(socket, null);
}
public FTPSession(Socket socket, String rootDirectory) {
this.controlSocket = socket; this.controlSocket = socket;
this.fileSystem = new FTPFileSystem(); this.fileSystem = new FTPFileSystem(rootDirectory);
this.dataConnection = null; this.dataConnection = null;
} }

View File

@@ -1,15 +1,20 @@
package be.gyu.android.server.ftp; package be.gyu.android.server.ftp;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.documentfile.provider.DocumentFile;
import android.Manifest; import android.Manifest;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -18,9 +23,17 @@ public class MainActivity extends AppCompatActivity {
private static final int PERMISSION_REQUEST_CODE = 100; private static final int PERMISSION_REQUEST_CODE = 100;
private TextView statusTextView; private TextView statusTextView;
private TextView portTextView;
private Button startButton; private Button startButton;
private Button stopButton; private Button stopButton;
private EditText portEditText;
private TextView rootDirPathTextView;
private Button selectDirButton;
private Button saveSettingsButton;
private FTPConfig config;
private ActivityResultLauncher<Uri> directoryPickerLauncher;
private boolean isServerRunning = false; private boolean isServerRunning = false;
@Override @Override
@@ -28,20 +41,107 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
config = new FTPConfig(this);
setupDirectoryPicker();
initViews(); initViews();
loadSettings();
setupListeners(); setupListeners();
requestPermissions(); requestPermissions();
} }
private void setupDirectoryPicker() {
directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.OpenDocumentTree(),
uri -> {
if (uri != null) {
getContentResolver().takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
);
DocumentFile pickedDir = DocumentFile.fromTreeUri(this, uri);
if (pickedDir != null && pickedDir.isDirectory()) {
String path = uri.getPath();
if (path != null) {
path = path.replace("/tree/primary:", "/storage/emulated/0/");
path = path.replace("/tree/", "/");
}
rootDirPathTextView.setText(path != null ? path : uri.toString());
config.setRootDirectoryUri(uri.toString());
config.setRootDirectoryPath(path);
Toast.makeText(this, "Directory selected", Toast.LENGTH_SHORT).show();
}
}
}
);
}
private void initViews() { private void initViews() {
statusTextView = findViewById(R.id.statusTextView); statusTextView = findViewById(R.id.statusTextView);
portTextView = findViewById(R.id.portTextView);
startButton = findViewById(R.id.startButton); startButton = findViewById(R.id.startButton);
stopButton = findViewById(R.id.stopButton); stopButton = findViewById(R.id.stopButton);
portEditText = findViewById(R.id.portEditText);
rootDirPathTextView = findViewById(R.id.rootDirPathTextView);
selectDirButton = findViewById(R.id.selectDirButton);
saveSettingsButton = findViewById(R.id.saveSettingsButton);
}
private void loadSettings() {
int port = config.getPort();
String rootPath = config.getRootDirectoryPath();
portEditText.setText(String.valueOf(port));
portTextView.setText("Port: " + port);
if (rootPath != null && !rootPath.isEmpty()) {
rootDirPathTextView.setText(rootPath);
} else {
rootDirPathTextView.setText("Not selected");
}
} }
private void setupListeners() { private void setupListeners() {
startButton.setOnClickListener(v -> startFTPServer()); startButton.setOnClickListener(v -> startFTPServer());
stopButton.setOnClickListener(v -> stopFTPServer()); stopButton.setOnClickListener(v -> stopFTPServer());
selectDirButton.setOnClickListener(v -> selectDirectory());
saveSettingsButton.setOnClickListener(v -> saveSettings());
}
private void selectDirectory() {
directoryPickerLauncher.launch(null);
}
private void saveSettings() {
String portStr = portEditText.getText().toString().trim();
if (portStr.isEmpty()) {
Toast.makeText(this, "Please enter a port number", Toast.LENGTH_SHORT).show();
return;
}
int port;
try {
port = Integer.parseInt(portStr);
if (port < 1024 || port > 65535) {
Toast.makeText(this, "Port must be between 1024 and 65535", Toast.LENGTH_SHORT).show();
return;
}
} catch (NumberFormatException e) {
Toast.makeText(this, "Invalid port number", Toast.LENGTH_SHORT).show();
return;
}
if (!config.hasRootDirectory()) {
Toast.makeText(this, "Please select a root directory", Toast.LENGTH_SHORT).show();
return;
}
config.setPort(port);
portTextView.setText("Port: " + port);
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show();
} }
private void requestPermissions() { private void requestPermissions() {
@@ -72,8 +172,15 @@ public class MainActivity extends AppCompatActivity {
} }
private void startFTPServer() { private void startFTPServer() {
if (!config.hasRootDirectory()) {
Toast.makeText(this, "Please select a root directory first", Toast.LENGTH_LONG).show();
return;
}
Intent serviceIntent = new Intent(this, FTPService.class); Intent serviceIntent = new Intent(this, FTPService.class);
serviceIntent.setAction(FTPService.ACTION_START); serviceIntent.setAction(FTPService.ACTION_START);
serviceIntent.putExtra("port", config.getPort());
serviceIntent.putExtra("rootDir", config.getRootDirectoryPath());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent); startForegroundService(serviceIntent);
@@ -83,7 +190,7 @@ public class MainActivity extends AppCompatActivity {
isServerRunning = true; isServerRunning = true;
updateUI(); updateUI();
Toast.makeText(this, "FTP Server Started", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "FTP Server Started on port " + config.getPort(), Toast.LENGTH_SHORT).show();
} }
private void stopFTPServer() { private void stopFTPServer() {

View File

@@ -1,12 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity"> tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView <TextView
android:id="@+id/titleTextView" android:id="@+id/titleTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -64,4 +68,92 @@
app:layout_constraintTop_toBottomOf="@id/startButton" app:layout_constraintTop_toBottomOf="@id/startButton"
android:layout_marginTop="16dp" /> android:layout_marginTop="16dp" />
</androidx.constraintlayout.widget.ConstraintLayout> <View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#CCCCCC"
android:layout_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@id/stopButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/settingsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Settings"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginTop="24dp"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/portLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Port:"
android:textSize="16sp"
android:layout_marginTop="24dp"
app:layout_constraintTop_toBottomOf="@id/settingsTitle"
app:layout_constraintStart_toStartOf="parent" />
<EditText
android:id="@+id/portEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="number"
android:hint="2121"
android:layout_marginStart="16dp"
app:layout_constraintBaseline_toBaselineOf="@id/portLabel"
app:layout_constraintStart_toEndOf="@id/portLabel"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/rootDirLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Root Directory:"
android:textSize="16sp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/portLabel"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/rootDirPathTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Not selected"
android:textSize="14sp"
android:layout_marginTop="8dp"
android:padding="8dp"
android:background="@android:color/darker_gray"
android:ellipsize="start"
android:singleLine="true"
app:layout_constraintTop_toBottomOf="@id/rootDirLabel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/selectDirButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Select Directory"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/rootDirPathTextView"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/saveSettingsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Save Settings"
android:minWidth="150dp"
android:layout_marginTop="24dp"
app:layout_constraintTop_toBottomOf="@id/selectDirButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>