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:
63
app/src/main/java/be/gyu/android/server/ftp/FTPConfig.java
Normal file
63
app/src/main/java/be/gyu/android/server/ftp/FTPConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,19 @@ public class FTPServer {
|
||||
private Thread acceptThread;
|
||||
private boolean isRunning = false;
|
||||
private int port;
|
||||
private String rootDirectory;
|
||||
|
||||
public FTPServer() {
|
||||
this(DEFAULT_PORT);
|
||||
this(DEFAULT_PORT, null);
|
||||
}
|
||||
|
||||
public FTPServer(int port) {
|
||||
this(port, null);
|
||||
}
|
||||
|
||||
public FTPServer(int port, String rootDirectory) {
|
||||
this.port = port;
|
||||
this.rootDirectory = rootDirectory;
|
||||
this.executorService = Executors.newFixedThreadPool(MAX_CONNECTIONS);
|
||||
}
|
||||
|
||||
@@ -46,7 +52,7 @@ public class FTPServer {
|
||||
Socket clientSocket = serverSocket.accept();
|
||||
Log.i(TAG, "New client connection from: " + clientSocket.getInetAddress());
|
||||
|
||||
FTPSession session = new FTPSession(clientSocket);
|
||||
FTPSession session = new FTPSession(clientSocket, rootDirectory);
|
||||
executorService.execute(session);
|
||||
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -35,7 +35,9 @@ public class FTPService extends Service {
|
||||
String action = intent.getAction();
|
||||
|
||||
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)) {
|
||||
stopFTPServer();
|
||||
}
|
||||
@@ -44,19 +46,19 @@ public class FTPService extends Service {
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void startFTPServer() {
|
||||
private void startFTPServer(int port, String rootDir) {
|
||||
if (ftpServer != null && ftpServer.isRunning()) {
|
||||
Log.w(TAG, "FTP Server is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
ftpServer = new FTPServer();
|
||||
ftpServer = new FTPServer(port, rootDir);
|
||||
ftpServer.start();
|
||||
|
||||
Notification notification = createNotification("FTP Server is running on port " + ftpServer.getPort());
|
||||
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() {
|
||||
|
||||
@@ -24,8 +24,12 @@ public class FTPSession implements Runnable {
|
||||
private String transferType = "A"; // A = ASCII, I = Binary
|
||||
|
||||
public FTPSession(Socket socket) {
|
||||
this(socket, null);
|
||||
}
|
||||
|
||||
public FTPSession(Socket socket, String rootDirectory) {
|
||||
this.controlSocket = socket;
|
||||
this.fileSystem = new FTPFileSystem();
|
||||
this.fileSystem = new FTPFileSystem(rootDirectory);
|
||||
this.dataConnection = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package be.gyu.android.server.ftp;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -18,9 +23,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
private static final int PERMISSION_REQUEST_CODE = 100;
|
||||
|
||||
private TextView statusTextView;
|
||||
private TextView portTextView;
|
||||
private Button startButton;
|
||||
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;
|
||||
|
||||
@Override
|
||||
@@ -28,20 +41,107 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
config = new FTPConfig(this);
|
||||
|
||||
setupDirectoryPicker();
|
||||
initViews();
|
||||
loadSettings();
|
||||
setupListeners();
|
||||
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() {
|
||||
statusTextView = findViewById(R.id.statusTextView);
|
||||
portTextView = findViewById(R.id.portTextView);
|
||||
startButton = findViewById(R.id.startButton);
|
||||
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() {
|
||||
startButton.setOnClickListener(v -> startFTPServer());
|
||||
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() {
|
||||
@@ -72,8 +172,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
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);
|
||||
serviceIntent.setAction(FTPService.ACTION_START);
|
||||
serviceIntent.putExtra("port", config.getPort());
|
||||
serviceIntent.putExtra("rootDir", config.getRootDirectoryPath());
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
@@ -83,7 +190,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
isServerRunning = true;
|
||||
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() {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<?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:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleTextView"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -64,4 +68,92 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/startButton"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user