From e6e4ef7df5394f3418dc097e7c1e00824d0cd03e Mon Sep 17 00:00:00 2001 From: Gyubin Han Date: Fri, 2 Jan 2026 00:58:17 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20macOS=20=ED=95=9C=EA=B8=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20=EC=9C=A0=EB=8B=88=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS NFD와 Android NFC 유니코드 정규화 차이로 인한 한글 파일명 처리 오류 수정. 파일 검색 시 NFD/NFC 모두 지원하도록 개선하여 macOS에서 업로드/다운로드/삭제 작업이 정상 동작함. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .README.md.un~ | Bin 1801 -> 0 bytes .../gyu/android/server/ftp/FTPFileSystem.java | 367 +++++++++++------- .../be/gyu/android/server/ftp/FTPServer.java | 20 +- .../be/gyu/android/server/ftp/FTPService.java | 14 +- .../be/gyu/android/server/ftp/FTPSession.java | 181 ++++++++- .../gyu/android/server/ftp/MainActivity.java | 5 +- 6 files changed, 412 insertions(+), 175 deletions(-) delete mode 100644 .README.md.un~ diff --git a/.README.md.un~ b/.README.md.un~ deleted file mode 100644 index e58b8ebaf931fb494e15020cd34caf714e5d079f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1801 zcmeHIJxc>Y5M86zcA}LFX~j+}6$G&h1W{W-v`C>*V`F6^7mdk*QFA2GO^l%8#}y$O zh!FpPjW$*mR-$C*I<0kPb9V&m2o@Q5H+wrX%iX-0*_puN@$}Mo@Qr!;rn}#~I;?mf z!Xf`>u{QR)^P+u(ht5Xsu6w(lHyC4`h;4aO;Z{nMCjtsND_)WD6rY&#LoUN1cWW89 z@~p~1Xt?4~7O1sH4zhaK>=Rj0Jawg>WFjc} zMsbgEHe0%h83_3HE)*SY?5pS3xr&Cth@y$s4xi*klo8iT6iqH^&TdOLF9X;$^NJ=` zr>Qa7M8y&qF?k?p0ATAL2ySIFwbDJLlb{J+E1PmRiydpv1&*7tq;W6`R!YYnhiVw( zAe!N|qL4*A1dTK<1fN`34u%42!PRvR`KaCO^kb@A8avlrD@$XsG!~NUWhaOHoT0&5 eg=A;t&lDEYqdk6In&=845KaH(DjAHtU;O}ViYeRx diff --git a/app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java b/app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java index 90aa352..a0990d0 100644 --- a/app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java +++ b/app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java @@ -1,9 +1,14 @@ package be.gyu.android.server.ftp; +import android.content.Context; +import android.net.Uri; import android.os.Environment; import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import java.io.File; +import java.text.Normalizer; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -13,34 +18,41 @@ import java.util.Locale; public class FTPFileSystem { private static final String TAG = "FTPFileSystem"; - private final File rootDirectory; - private File currentDirectory; + private final Context context; + private final DocumentFile rootDirectory; + private DocumentFile currentDirectory; + private final boolean useDocumentFile; - public FTPFileSystem() { - this(null); + public FTPFileSystem(Context context) { + this(context, null); } - public FTPFileSystem(String rootDirectoryPath) { - if (rootDirectoryPath != null && !rootDirectoryPath.isEmpty()) { - // Use user-specified directory - File userDir = new File(rootDirectoryPath); - if (userDir.exists() && userDir.isDirectory()) { + public FTPFileSystem(Context context, Uri rootDirectoryUri) { + this.context = context; + + if (rootDirectoryUri != null) { + // Use user-specified directory via DocumentFile + DocumentFile userDir = DocumentFile.fromTreeUri(context, rootDirectoryUri); + if (userDir != null && userDir.exists() && userDir.isDirectory()) { this.rootDirectory = userDir; - Log.i(TAG, "Using user-specified root: " + rootDirectory.getAbsolutePath()); + this.useDocumentFile = true; + Log.i(TAG, "Using user-specified root (DocumentFile): " + rootDirectoryUri); } else { - Log.w(TAG, "User-specified directory does not exist: " + rootDirectoryPath); + Log.w(TAG, "User-specified directory does not exist: " + rootDirectoryUri); this.rootDirectory = getDefaultRootDirectory(); + this.useDocumentFile = false; } } else { // Use default directory this.rootDirectory = getDefaultRootDirectory(); + this.useDocumentFile = false; } this.currentDirectory = rootDirectory; - Log.d(TAG, "File system initialized. Root: " + rootDirectory.getAbsolutePath()); + Log.d(TAG, "File system initialized. Root: " + getDisplayPath(rootDirectory)); } - private File getDefaultRootDirectory() { + private DocumentFile getDefaultRootDirectory() { File externalStorage = Environment.getExternalStorageDirectory(); File ftpServerDir = new File(externalStorage, "FTPServer"); @@ -48,14 +60,22 @@ public class FTPFileSystem { if (!ftpServerDir.exists()) { if (ftpServerDir.mkdirs()) { Log.i(TAG, "Root directory created: " + ftpServerDir.getAbsolutePath()); - return ftpServerDir; } else { Log.w(TAG, "Failed to create root directory, using external storage root"); - return externalStorage; + ftpServerDir = externalStorage; } - } else { - return ftpServerDir; } + + return DocumentFile.fromFile(ftpServerDir); + } + + private String getDisplayPath(DocumentFile file) { + if (file == null) return "null"; + Uri uri = file.getUri(); + if (uri != null) { + return uri.toString(); + } + return file.getName(); } public String getCurrentPath() { @@ -64,71 +84,55 @@ public class FTPFileSystem { } public boolean changeDirectory(String path) { - File newDir; + DocumentFile newDir; if (path.startsWith("/")) { // Absolute path - newDir = new File(rootDirectory, path.substring(1)); + newDir = findDocumentFile(rootDirectory, path.substring(1)); } else { // Relative path - newDir = new File(currentDirectory, path); + newDir = findDocumentFile(currentDirectory, path); } - try { - String canonicalPath = newDir.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - + if (newDir != null && newDir.exists() && newDir.isDirectory()) { // Security check: prevent escaping root directory - if (!canonicalPath.startsWith(rootPath)) { - Log.w(TAG, "Attempted to escape root directory: " + canonicalPath); - return false; - } - - if (newDir.exists() && newDir.isDirectory()) { + if (isSubDirectory(newDir, rootDirectory)) { currentDirectory = newDir; - Log.d(TAG, "Changed directory to: " + currentDirectory.getAbsolutePath()); + Log.d(TAG, "Changed directory to: " + getDisplayPath(currentDirectory)); return true; } else { - Log.w(TAG, "Directory does not exist: " + newDir.getAbsolutePath()); + Log.w(TAG, "Attempted to escape root directory"); return false; } - } catch (Exception e) { - Log.e(TAG, "Error changing directory: " + e.getMessage()); + } else { + Log.w(TAG, "Directory does not exist: " + path); return false; } } public boolean changeToParentDirectory() { - File parent = currentDirectory.getParentFile(); + DocumentFile parent = currentDirectory.getParentFile(); if (parent == null) { return false; } - try { - String parentPath = parent.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - - // Cannot go above root directory - if (!parentPath.startsWith(rootPath)) { - return false; - } - - currentDirectory = parent; - Log.d(TAG, "Changed to parent directory: " + currentDirectory.getAbsolutePath()); - return true; - } catch (Exception e) { - Log.e(TAG, "Error changing to parent directory: " + e.getMessage()); + // Cannot go above root directory + if (!isSubDirectory(parent, rootDirectory) && !isSameFile(parent, rootDirectory)) { return false; } + + currentDirectory = parent; + Log.d(TAG, "Changed to parent directory: " + getDisplayPath(currentDirectory)); + return true; } - public List listFiles() { - File[] files = currentDirectory.listFiles(); - List fileList = new ArrayList<>(); + public List listFiles() { + DocumentFile[] files = currentDirectory.listFiles(); + List fileList = new ArrayList<>(); if (files != null) { - for (File file : files) { + for (DocumentFile file : files) { fileList.add(file); } } @@ -137,19 +141,19 @@ public class FTPFileSystem { } public String formatFileList(boolean detailed) { - List files = listFiles(); + List files = listFiles(); StringBuilder sb = new StringBuilder(); if (detailed) { // LIST format: Unix-style detailed listing SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd HH:mm", Locale.US); - for (File file : files) { + for (DocumentFile file : files) { sb.append(formatDetailedFile(file, dateFormat)).append("\r\n"); } } else { // NLST format: names only - for (File file : files) { + for (DocumentFile file : files) { sb.append(file.getName()).append("\r\n"); } } @@ -157,7 +161,7 @@ public class FTPFileSystem { return sb.toString(); } - private String formatDetailedFile(File file, SimpleDateFormat dateFormat) { + private String formatDetailedFile(DocumentFile file, SimpleDateFormat dateFormat) { // Format: drwxrwxrwx 1 owner group size date name String permissions = file.isDirectory() ? "drwxr-xr-x" : "-rw-r--r--"; String owner = "ftp"; @@ -171,123 +175,198 @@ public class FTPFileSystem { } public boolean makeDirectory(String dirName) { - File newDir = new File(currentDirectory, dirName); + DocumentFile newDir = currentDirectory.createDirectory(dirName); - try { - String canonicalPath = newDir.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - - if (!canonicalPath.startsWith(rootPath)) { - return false; - } - - if (newDir.mkdir()) { - Log.i(TAG, "Directory created: " + newDir.getAbsolutePath()); - return true; - } else { - Log.w(TAG, "Failed to create directory: " + newDir.getAbsolutePath()); - return false; - } - } catch (Exception e) { - Log.e(TAG, "Error creating directory: " + e.getMessage()); + if (newDir != null) { + Log.i(TAG, "Directory created: " + dirName); + return true; + } else { + Log.w(TAG, "Failed to create directory: " + dirName); return false; } } public boolean removeDirectory(String dirName) { - File dir = new File(currentDirectory, dirName); + DocumentFile dir = findFileWithNormalization(currentDirectory, dirName); - try { - String canonicalPath = dir.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - - if (!canonicalPath.startsWith(rootPath)) { - return false; - } - - if (dir.exists() && dir.isDirectory() && dir.delete()) { - Log.i(TAG, "Directory removed: " + dir.getAbsolutePath()); - return true; - } else { - Log.w(TAG, "Failed to remove directory: " + dir.getAbsolutePath()); - return false; - } - } catch (Exception e) { - Log.e(TAG, "Error removing directory: " + e.getMessage()); + if (dir != null && dir.exists() && dir.isDirectory() && dir.delete()) { + Log.i(TAG, "Directory removed: " + dirName); + return true; + } else { + Log.w(TAG, "Failed to remove directory: " + dirName); return false; } } public boolean deleteFile(String fileName) { - File file = new File(currentDirectory, fileName); + DocumentFile file = findFileWithNormalization(currentDirectory, fileName); - try { - String canonicalPath = file.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - - if (!canonicalPath.startsWith(rootPath)) { - return false; - } - - if (file.exists() && file.isFile() && file.delete()) { - Log.i(TAG, "File deleted: " + file.getAbsolutePath()); - return true; - } else { - Log.w(TAG, "Failed to delete file: " + file.getAbsolutePath()); - return false; - } - } catch (Exception e) { - Log.e(TAG, "Error deleting file: " + e.getMessage()); + if (file != null && file.exists() && file.isFile() && file.delete()) { + Log.i(TAG, "File deleted: " + fileName); + return true; + } else { + Log.w(TAG, "Failed to delete file: " + fileName); return false; } } - public File getFile(String fileName) { - File file = new File(currentDirectory, fileName); - - try { - String canonicalPath = file.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - - if (!canonicalPath.startsWith(rootPath)) { - return null; - } - - return file.exists() ? file : null; - } catch (Exception e) { - Log.e(TAG, "Error getting file: " + e.getMessage()); - return null; - } + public DocumentFile getFile(String fileName) { + return findFileWithNormalization(currentDirectory, fileName); } public long getFileSize(String fileName) { - File file = getFile(fileName); + DocumentFile file = getFile(fileName); return (file != null && file.isFile()) ? file.length() : -1; } - private String getRelativePath(File file) { - try { - String filePath = file.getCanonicalPath(); - String rootPath = rootDirectory.getCanonicalPath(); - - if (filePath.equals(rootPath)) { - return ""; - } else if (filePath.startsWith(rootPath + File.separator)) { - return filePath.substring(rootPath.length() + 1).replace(File.separator, "/"); - } else { - return ""; - } - } catch (Exception e) { - Log.e(TAG, "Error getting relative path: " + e.getMessage()); + private String getRelativePath(DocumentFile file) { + if (file == null) { return ""; } + + if (isSameFile(file, rootDirectory)) { + return ""; + } + + List pathParts = new ArrayList<>(); + DocumentFile current = file; + + while (current != null && !isSameFile(current, rootDirectory)) { + pathParts.add(0, current.getName()); + current = current.getParentFile(); + } + + if (current == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < pathParts.size(); i++) { + sb.append(pathParts.get(i)); + if (i < pathParts.size() - 1) { + sb.append("/"); + } + } + + return sb.toString(); } - public File getRootDirectory() { + private boolean isSameFile(DocumentFile file1, DocumentFile file2) { + if (file1 == null || file2 == null) { + return false; + } + return file1.getUri().equals(file2.getUri()); + } + + private boolean isSubDirectory(DocumentFile child, DocumentFile parent) { + if (child == null || parent == null) { + return false; + } + + if (isSameFile(child, parent)) { + return true; + } + + DocumentFile current = child; + while (current != null) { + if (isSameFile(current, parent)) { + return true; + } + current = current.getParentFile(); + } + + return false; + } + + private DocumentFile findDocumentFile(DocumentFile parent, String path) { + if (path == null || path.isEmpty()) { + return parent; + } + + String[] parts = path.split("/"); + DocumentFile current = parent; + + for (String part : parts) { + if (part.isEmpty() || part.equals(".")) { + continue; + } + + if (part.equals("..")) { + DocumentFile parentFile = current.getParentFile(); + if (parentFile != null && isSubDirectory(parentFile, rootDirectory)) { + current = parentFile; + } + continue; + } + + DocumentFile next = findFileWithNormalization(current, part); + if (next == null) { + return null; + } + current = next; + } + + return current; + } + + public DocumentFile getRootDirectory() { return rootDirectory; } - public File getCurrentDirectory() { + public DocumentFile getCurrentDirectory() { return currentDirectory; } + + public Context getContext() { + return context; + } + + /** + * Find a file with Unicode normalization handling for macOS compatibility. + * This method tries multiple approaches to find the file: + * 1. Direct match with given name + * 2. Try NFD normalization (macOS format) + * 3. Manual search with NFC normalization comparison + */ + private DocumentFile findFileWithNormalization(DocumentFile parent, String fileName) { + if (parent == null || fileName == null || fileName.isEmpty()) { + return null; + } + + // Try to find file with exact name first + DocumentFile file = parent.findFile(fileName); + if (file != null && file.exists()) { + return file; + } + + // If not found, try with NFD normalization (for macOS compatibility) + String nfdFileName = Normalizer.normalize(fileName, Normalizer.Form.NFD); + if (!nfdFileName.equals(fileName)) { + file = parent.findFile(nfdFileName); + if (file != null && file.exists()) { + return file; + } + } + + // If still not found, manually search through all files + // This handles cases where filesystem has mixed normalization + DocumentFile[] files = parent.listFiles(); + if (files != null) { + String normalizedFileName = Normalizer.normalize(fileName, Normalizer.Form.NFC); + for (DocumentFile f : files) { + String name = f.getName(); + if (name != null) { + // Compare both NFC normalized forms + String normalizedName = Normalizer.normalize(name, Normalizer.Form.NFC); + if (normalizedName.equals(normalizedFileName)) { + Log.d(TAG, "Found file with normalized match: '" + name + "' == '" + fileName + "'"); + return f; + } + } + } + } + + Log.d(TAG, "File not found with any normalization: " + fileName); + return null; + } } diff --git a/app/src/main/java/be/gyu/android/server/ftp/FTPServer.java b/app/src/main/java/be/gyu/android/server/ftp/FTPServer.java index c7565c2..354541c 100644 --- a/app/src/main/java/be/gyu/android/server/ftp/FTPServer.java +++ b/app/src/main/java/be/gyu/android/server/ftp/FTPServer.java @@ -1,5 +1,7 @@ package be.gyu.android.server.ftp; +import android.content.Context; +import android.net.Uri; import android.util.Log; import java.io.IOException; @@ -19,19 +21,21 @@ public class FTPServer { private Thread acceptThread; private boolean isRunning = false; private int port; - private String rootDirectory; + private Context context; + private Uri rootDirectoryUri; - public FTPServer() { - this(DEFAULT_PORT, null); + public FTPServer(Context context) { + this(context, DEFAULT_PORT, null); } - public FTPServer(int port) { - this(port, null); + public FTPServer(Context context, int port) { + this(context, port, null); } - public FTPServer(int port, String rootDirectory) { + public FTPServer(Context context, int port, Uri rootDirectoryUri) { + this.context = context; this.port = port; - this.rootDirectory = rootDirectory; + this.rootDirectoryUri = rootDirectoryUri; this.executorService = Executors.newFixedThreadPool(MAX_CONNECTIONS); } @@ -52,7 +56,7 @@ public class FTPServer { Socket clientSocket = serverSocket.accept(); Log.i(TAG, "New client connection from: " + clientSocket.getInetAddress()); - FTPSession session = new FTPSession(clientSocket, rootDirectory); + FTPSession session = new FTPSession(clientSocket, context, rootDirectoryUri); executorService.execute(session); } catch (IOException e) { diff --git a/app/src/main/java/be/gyu/android/server/ftp/FTPService.java b/app/src/main/java/be/gyu/android/server/ftp/FTPService.java index dac509d..0a9c2d7 100644 --- a/app/src/main/java/be/gyu/android/server/ftp/FTPService.java +++ b/app/src/main/java/be/gyu/android/server/ftp/FTPService.java @@ -36,8 +36,12 @@ public class FTPService extends Service { if (ACTION_START.equals(action)) { int port = intent.getIntExtra("port", 2121); - String rootDir = intent.getStringExtra("rootDir"); - startFTPServer(port, rootDir); + String rootDirUriString = intent.getStringExtra("rootDirUri"); + android.net.Uri rootDirUri = null; + if (rootDirUriString != null && !rootDirUriString.isEmpty()) { + rootDirUri = android.net.Uri.parse(rootDirUriString); + } + startFTPServer(port, rootDirUri); } else if (ACTION_STOP.equals(action)) { stopFTPServer(); } @@ -46,19 +50,19 @@ public class FTPService extends Service { return START_STICKY; } - private void startFTPServer(int port, String rootDir) { + private void startFTPServer(int port, android.net.Uri rootDirUri) { if (ftpServer != null && ftpServer.isRunning()) { Log.w(TAG, "FTP Server is already running"); return; } - ftpServer = new FTPServer(port, rootDir); + ftpServer = new FTPServer(this, port, rootDirUri); ftpServer.start(); Notification notification = createNotification("FTP Server is running on port " + ftpServer.getPort()); startForeground(NOTIFICATION_ID, notification); - Log.i(TAG, "FTP Server started on port " + port + " with root: " + rootDir); + Log.i(TAG, "FTP Server started on port " + port + " with root URI: " + rootDirUri); } private void stopFTPServer() { diff --git a/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java b/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java index 297e7c0..e7e0af1 100644 --- a/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java +++ b/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java @@ -1,13 +1,21 @@ package be.gyu.android.server.ftp; +import android.content.Context; +import android.net.Uri; import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.io.OutputStreamWriter; import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; public class FTPSession implements Runnable { private static final String TAG = "FTPSession"; @@ -22,22 +30,24 @@ public class FTPSession implements Runnable { private FTPFileSystem fileSystem; private FTPDataConnection dataConnection; private String transferType = "A"; // A = ASCII, I = Binary + private boolean useUtf8 = true; // UTF-8 enabled by default for better compatibility - public FTPSession(Socket socket) { - this(socket, null); + public FTPSession(Socket socket, Context context) { + this(socket, context, null); } - public FTPSession(Socket socket, String rootDirectory) { + public FTPSession(Socket socket, Context context, Uri rootDirectoryUri) { this.controlSocket = socket; - this.fileSystem = new FTPFileSystem(rootDirectory); + this.fileSystem = new FTPFileSystem(context, rootDirectoryUri); this.dataConnection = null; } @Override public void run() { try { - reader = new BufferedReader(new InputStreamReader(controlSocket.getInputStream())); - writer = new BufferedWriter(new OutputStreamWriter(controlSocket.getOutputStream())); + // Use UTF-8 encoding for better international character support + reader = new BufferedReader(new InputStreamReader(controlSocket.getInputStream(), StandardCharsets.UTF_8)); + writer = new BufferedWriter(new OutputStreamWriter(controlSocket.getOutputStream(), StandardCharsets.UTF_8)); Log.d(TAG, "Client connected: " + controlSocket.getInetAddress()); sendResponse(FTPResponse.SERVICE_READY, "FTP Server ready"); @@ -120,6 +130,12 @@ public class FTPSession implements Runnable { case "NOOP": handleNoop(); break; + case "FEAT": + handleFeat(); + break; + case "OPTS": + handleOpts(argument); + break; default: sendResponse(FTPResponse.COMMAND_NOT_IMPLEMENTED_502, "Command not implemented"); break; @@ -158,6 +174,8 @@ public class FTPSession implements Runnable { return; } String path = fileSystem.getCurrentPath(); + // Convert path to NFD format for MacOS compatibility + path = denormalizeFilename(path); sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + path + "\" is current directory"); } @@ -172,8 +190,14 @@ public class FTPSession implements Runnable { return; } + // Normalize path for internal use (NFD -> NFC) + path = normalizeFilename(path); + if (fileSystem.changeDirectory(path)) { - sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath()); + String currentPath = fileSystem.getCurrentPath(); + // Convert path to NFD format for MacOS compatibility + currentPath = denormalizeFilename(currentPath); + sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + currentPath); } else { sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to change directory"); } @@ -186,7 +210,10 @@ public class FTPSession implements Runnable { } if (fileSystem.changeToParentDirectory()) { - sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath()); + String currentPath = fileSystem.getCurrentPath(); + // Convert path to NFD format for MacOS compatibility + currentPath = denormalizeFilename(currentPath); + sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + currentPath); } else { sendResponse(FTPResponse.FILE_UNAVAILABLE, "Already at root directory"); } @@ -204,6 +231,8 @@ public class FTPSession implements Runnable { } String fileList = fileSystem.formatFileList(true); + // Convert filenames to NFD format for MacOS compatibility + fileList = convertFileListToNFD(fileList); sendResponse(FTPResponse.FILE_STATUS_OK, "Opening data connection for directory list"); @@ -235,6 +264,8 @@ public class FTPSession implements Runnable { } String fileList = fileSystem.formatFileList(false); + // Convert filenames to NFD format for MacOS compatibility + fileList = convertFileListToNFD(fileList); sendResponse(FTPResponse.FILE_STATUS_OK, "Opening data connection for name list"); @@ -265,8 +296,13 @@ public class FTPSession implements Runnable { return; } + // Normalize filename for MacOS compatibility (NFD -> NFC for storage) + dirName = normalizeFilename(dirName); + if (fileSystem.makeDirectory(dirName)) { String newPath = fileSystem.getCurrentPath() + "/" + dirName; + // Convert path to NFD format for MacOS compatibility (NFC -> NFD for display) + newPath = denormalizeFilename(newPath); sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + newPath + "\" directory created"); } else { sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to create directory"); @@ -284,6 +320,9 @@ public class FTPSession implements Runnable { return; } + // Normalize filename for MacOS compatibility + dirName = normalizeFilename(dirName); + if (fileSystem.removeDirectory(dirName)) { sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory removed"); } else { @@ -302,6 +341,9 @@ public class FTPSession implements Runnable { return; } + // Normalize filename for MacOS compatibility + fileName = normalizeFilename(fileName); + if (fileSystem.deleteFile(fileName)) { sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "File deleted"); } else { @@ -320,6 +362,9 @@ public class FTPSession implements Runnable { return; } + // Normalize filename for MacOS compatibility + fileName = normalizeFilename(fileName); + long size = fileSystem.getFileSize(fileName); if (size >= 0) { sendResponse(FTPResponse.FILE_STATUS, String.valueOf(size)); @@ -395,7 +440,10 @@ public class FTPSession implements Runnable { return; } - java.io.File file = fileSystem.getFile(fileName); + // Normalize filename for MacOS compatibility + fileName = normalizeFilename(fileName); + + DocumentFile file = fileSystem.getFile(fileName); if (file == null || !file.exists() || !file.isFile()) { sendResponse(FTPResponse.FILE_UNAVAILABLE, "File not found"); dataConnection.close(); @@ -407,14 +455,14 @@ public class FTPSession implements Runnable { if (dataConnection.acceptConnection()) { try { - java.io.FileInputStream fis = new java.io.FileInputStream(file); - if (dataConnection.transferStream(fis)) { + InputStream fis = fileSystem.getContext().getContentResolver().openInputStream(file.getUri()); + if (fis != null && dataConnection.transferStream(fis)) { fis.close(); dataConnection.close(); sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Transfer complete"); Log.i(TAG, "File sent: " + fileName + " (" + file.length() + " bytes)"); } else { - fis.close(); + if (fis != null) fis.close(); dataConnection.close(); sendResponse(FTPResponse.CONNECTION_CLOSED, "Transfer failed"); } @@ -447,20 +495,37 @@ public class FTPSession implements Runnable { return; } - java.io.File file = new java.io.File(fileSystem.getCurrentDirectory(), fileName); + // Normalize filename for MacOS compatibility + fileName = normalizeFilename(fileName); + + // Create or get the file in the current directory + DocumentFile currentDir = fileSystem.getCurrentDirectory(); + DocumentFile file = currentDir.findFile(fileName); + + // If file doesn't exist, create it + if (file == null) { + file = currentDir.createFile("application/octet-stream", fileName); + } + + if (file == null) { + sendResponse(FTPResponse.FILE_UNAVAILABLE, "Cannot create file"); + dataConnection.close(); + dataConnection = null; + return; + } sendResponse(FTPResponse.FILE_STATUS_OK, "Opening data connection for " + fileName); if (dataConnection.acceptConnection()) { try { - java.io.FileOutputStream fos = new java.io.FileOutputStream(file); - if (dataConnection.receiveStream(fos)) { + OutputStream fos = fileSystem.getContext().getContentResolver().openOutputStream(file.getUri(), "wt"); + if (fos != null && dataConnection.receiveStream(fos)) { fos.close(); dataConnection.close(); sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Transfer complete"); Log.i(TAG, "File received: " + fileName + " (" + file.length() + " bytes)"); } else { - fos.close(); + if (fos != null) fos.close(); dataConnection.close(); sendResponse(FTPResponse.CONNECTION_CLOSED, "Transfer failed"); } @@ -481,6 +546,90 @@ public class FTPSession implements Runnable { sendResponse(FTPResponse.COMMAND_OK, "OK"); } + private void handleFeat() throws IOException { + // Send list of supported features + writer.write("211-Features:\r\n"); + writer.write(" UTF8\r\n"); + writer.write(" SIZE\r\n"); + writer.write(" PASV\r\n"); + writer.write("211 End\r\n"); + writer.flush(); + Log.d(TAG, "FEAT command handled"); + } + + private void handleOpts(String options) throws IOException { + if (options.isEmpty()) { + sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No options specified"); + return; + } + + String[] parts = options.split("\\s+", 2); + String option = parts[0].toUpperCase(); + String value = parts.length > 1 ? parts[1].toUpperCase() : ""; + + if (option.equals("UTF8")) { + if (value.equals("ON") || value.isEmpty()) { + useUtf8 = true; + sendResponse(FTPResponse.COMMAND_OK, "UTF8 enabled"); + Log.d(TAG, "UTF-8 encoding enabled"); + } else if (value.equals("OFF")) { + useUtf8 = false; + sendResponse(FTPResponse.COMMAND_OK, "UTF8 disabled"); + Log.d(TAG, "UTF-8 encoding disabled"); + } else { + sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "Invalid UTF8 option"); + } + } else { + sendResponse(FTPResponse.COMMAND_NOT_IMPLEMENTED_FOR_PARAMETER, "Option not supported"); + } + } + + /** + * Normalize filename from MacOS NFD (Decomposed) to NFC (Composed) format. + * MacOS uses NFD normalization for filenames, which can cause issues on Android/Windows. + * This method converts filenames to NFC format for compatibility. + * Used when RECEIVING filenames from client (STOR, RETR, DELE, etc.) + */ + private String normalizeFilename(String filename) { + if (filename == null || filename.isEmpty()) { + return filename; + } + // Normalize to NFC (Canonical Decomposition, followed by Canonical Composition) + String normalized = Normalizer.normalize(filename, Normalizer.Form.NFC); + Log.d(TAG, "Filename normalized NFD->NFC: '" + filename + "' -> '" + normalized + "'"); + return normalized; + } + + /** + * Normalize filename to NFD (Decomposed) format for MacOS compatibility. + * MacOS expects filenames in NFD format for proper display. + * This method converts filenames from NFC to NFD format. + * Used when SENDING filenames to client (LIST, NLST, PWD, etc.) + */ + private String denormalizeFilename(String filename) { + if (filename == null || filename.isEmpty()) { + return filename; + } + // Normalize to NFD (Canonical Decomposition) for MacOS + String normalized = Normalizer.normalize(filename, Normalizer.Form.NFD); + Log.d(TAG, "Filename normalized NFC->NFD: '" + filename + "' -> '" + normalized + "'"); + return normalized; + } + + /** + * Convert entire file list to NFD format for MacOS compatibility. + * This method normalizes all filenames in the file list string to NFD format. + */ + private String convertFileListToNFD(String fileList) { + if (fileList == null || fileList.isEmpty()) { + return fileList; + } + // Normalize entire string to NFD for MacOS + String normalized = Normalizer.normalize(fileList, Normalizer.Form.NFD); + Log.d(TAG, "File list converted to NFD for MacOS"); + return normalized; + } + private void sendResponse(int code, String message) throws IOException { String response = FTPResponse.format(code, message); writer.write(response); diff --git a/app/src/main/java/be/gyu/android/server/ftp/MainActivity.java b/app/src/main/java/be/gyu/android/server/ftp/MainActivity.java index 9fa381a..74de3b8 100644 --- a/app/src/main/java/be/gyu/android/server/ftp/MainActivity.java +++ b/app/src/main/java/be/gyu/android/server/ftp/MainActivity.java @@ -184,7 +184,8 @@ public class MainActivity extends AppCompatActivity { } } - if (!config.hasRootDirectory()) { + String rootDirUri = config.getRootDirectoryUri(); + if (rootDirUri == null || rootDirUri.isEmpty()) { Toast.makeText(this, "Please select a root directory first", Toast.LENGTH_LONG).show(); return; } @@ -192,7 +193,7 @@ public class MainActivity extends AppCompatActivity { Intent serviceIntent = new Intent(this, FTPService.class); serviceIntent.setAction(FTPService.ACTION_START); serviceIntent.putExtra("port", config.getPort()); - serviceIntent.putExtra("rootDir", config.getRootDirectoryPath()); + serviceIntent.putExtra("rootDirUri", rootDirUri); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent);