From cb6c6807da5646527a9626e2b6b2628cdcef2026 Mon Sep 17 00:00:00 2001 From: xav Date: Fri, 30 Dec 2022 16:11:30 -0500 Subject: [PATCH 1/6] Scoped storage implementation of Android 11+ and also bump older versions onto it if they have issues with the old code. --- .../java/be/ppareit/swiftp/FsSettings.java | 27 + app/src/main/java/be/ppareit/swiftp/Util.java | 20 + .../swiftp/gui/PreferenceFragment.java | 118 +++- .../ppareit/swiftp/gui/UserEditFragment.java | 41 +- .../swiftp/server/CmdAbstractListing.java | 207 +++++- .../swiftp/server/CmdAbstractStore.java | 70 +- .../java/be/ppareit/swiftp/server/CmdCWD.java | 3 +- .../be/ppareit/swiftp/server/CmdDELE.java | 44 +- .../be/ppareit/swiftp/server/CmdHASH.java | 3 +- .../be/ppareit/swiftp/server/CmdLIST.java | 36 +- .../be/ppareit/swiftp/server/CmdMDTM.java | 3 +- .../be/ppareit/swiftp/server/CmdMFMT.java | 2 +- .../java/be/ppareit/swiftp/server/CmdMKD.java | 3 +- .../be/ppareit/swiftp/server/CmdMLSD.java | 50 +- .../be/ppareit/swiftp/server/CmdMLST.java | 55 +- .../be/ppareit/swiftp/server/CmdNLST.java | 8 +- .../java/be/ppareit/swiftp/server/CmdPWD.java | 22 +- .../be/ppareit/swiftp/server/CmdRETR.java | 99 ++- .../java/be/ppareit/swiftp/server/CmdRMD.java | 74 ++- .../be/ppareit/swiftp/server/CmdRNFR.java | 3 +- .../be/ppareit/swiftp/server/CmdRNTO.java | 3 +- .../java/be/ppareit/swiftp/server/FtpCmd.java | 123 +++- .../ppareit/swiftp/server/SessionThread.java | 66 ++ .../be/ppareit/swiftp/utils/FileUtil.java | 597 +++++++++++++++++- 24 files changed, 1479 insertions(+), 198 deletions(-) diff --git a/app/src/main/java/be/ppareit/swiftp/FsSettings.java b/app/src/main/java/be/ppareit/swiftp/FsSettings.java index 61749370..8c54dbdf 100644 --- a/app/src/main/java/be/ppareit/swiftp/FsSettings.java +++ b/app/src/main/java/be/ppareit/swiftp/FsSettings.java @@ -36,6 +36,7 @@ import java.util.List; import be.ppareit.swiftp.server.FtpUser; +import be.ppareit.swiftp.utils.FileUtil; public class FsSettings { @@ -110,6 +111,31 @@ public static boolean allowAnonymous() { } public static File getDefaultChrootDir() { + // Get the path from the app's MANAGE USERS chroot folder UI text field that the user will use during setup. + String subFix = null; + if (Util.useScopedStorage()) { + // The app's MANAGE USERS chroot folder UI selection cannot select the sd card at least on Android 11+. + // The picker does all that's needed there so that use should be switched with ADVANCED SETTINGS > + // WRITE EXTERNAL picker or just make it invisible when on A11+ ? Could also just pull open the same + // picker on both with A11+ or something else. It also presents possible conflicts with the Uri path + // eg "/storage/sd card/" verses "/sd card/Test/". + String s = FileUtil.cleanupUriStoragePath(FileUtil.getTreeUri()); + if (s != null && !s.contains("primary:")) { + final String chroot = FileUtil.getSdCardBaseFolderScopedStorage(); + // Need to return eg "/storage" for sd card and "/storage/emulated/0" for internal. + if (chroot != null && !chroot.isEmpty()) return new File(chroot); + // otherwise just get the other path from below. + } else if (s != null && s.contains("primary:")) { + // Fix for issue seen on Android 8.0: + // Had to implement over below as the below chroot is forced to + // getExternalStorageDirectory() when actual chroot may include further sub dirs. + subFix = s.replace("primary:", ""); + // At the moment, have to do it below, as a StackOverflow is happening on the test device + // here with any additional code for an unknown reason. + } + } + + // Original below incorrectly returns "/storage/emulated/0" for sd card with Android 11+ File chrootDir; if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { chrootDir = Environment.getExternalStorageDirectory(); @@ -123,6 +149,7 @@ public static File getDefaultChrootDir() { // but this will probably not be what the user wants return App.getAppContext().getFilesDir(); } + if (subFix != null) return new File(chrootDir, subFix); return chrootDir; } diff --git a/app/src/main/java/be/ppareit/swiftp/Util.java b/app/src/main/java/be/ppareit/swiftp/Util.java index 647637e7..5492b811 100644 --- a/app/src/main/java/be/ppareit/swiftp/Util.java +++ b/app/src/main/java/be/ppareit/swiftp/Util.java @@ -21,6 +21,8 @@ package be.ppareit.swiftp; import android.app.Activity; +import android.content.SharedPreferences; +import android.os.Build; import android.preference.PreferenceManager; import android.util.Log; @@ -34,6 +36,8 @@ abstract public class Util { final static String TAG = Util.class.getSimpleName(); + private static SharedPreferences sp = null; + private static boolean overrideSDKVer = false; public static byte byteOfInt(int value, int which) { int shift = which * 8; @@ -108,4 +112,20 @@ public static Date parseDate(String time) throws ParseException { SimpleDateFormat df = createSimpleDateFormat(); return df.parse(time); } + + /* + * Implemented mainly for Android 11+ where its forced but can work on earlier Android versions. + * Uses an override for when File fails to work during a test as the user sets up the app. + * */ + public static boolean useScopedStorage() { + if (sp == null) { + sp = PreferenceManager.getDefaultSharedPreferences(App.getAppContext()); + overrideSDKVer = sp.getBoolean("OverrideScopedStorageMinimum", false); + } + return Build.VERSION.SDK_INT >= 30 || overrideSDKVer; + } + + public static void reGetStorageOverride() { + sp = null; + } } diff --git a/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java b/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java index 23e37a13..b6e4f32e 100644 --- a/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java +++ b/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java @@ -26,6 +26,8 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.UriPermission; import android.content.pm.PackageManager; import android.content.res.Resources; import android.net.Uri; @@ -36,6 +38,7 @@ import android.preference.EditTextPreference; import android.preference.ListPreference; import android.preference.Preference; +import android.preference.PreferenceManager; import android.preference.PreferenceScreen; import android.preference.TwoStatePreference; import android.text.util.Linkify; @@ -44,9 +47,11 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; import net.vrallev.android.cat.Cat; +import java.io.File; import java.net.InetAddress; import java.util.List; @@ -55,7 +60,9 @@ import be.ppareit.swiftp.FsService; import be.ppareit.swiftp.FsSettings; import be.ppareit.swiftp.R; +import be.ppareit.swiftp.Util; import be.ppareit.swiftp.server.FtpUser; +import be.ppareit.swiftp.utils.FileUtil; /** * This is the main activity for swiftp, it enables the user to start the server service @@ -248,26 +255,119 @@ public void onPause() { public void onActivityResult(int requestCode, int resultCode, Intent resultData) { Cat.d("onActivityResult called"); if (requestCode == ACTION_OPEN_DOCUMENT_TREE && resultCode == Activity.RESULT_OK) { + if (resultData == null) return; Uri treeUri = resultData.getData(); + if (treeUri == null) return; String path = treeUri.getPath(); Cat.d("Action Open Document Tree on path " + path); + // ************************************* + // The order following here is critical. They must stay ordered as they are. + setPermissionToUseExternalStorage(treeUri); + tryToUpgradeToScopedStorage(treeUri); + scopedStorageChrootOverride(treeUri); + } + } - final CheckBoxPreference writeExternalStoragePref = findPref("writeExternalStorage"); - if (!":".equals(path.substring(path.length() - 1)) || path.contains("primary")) { - writeExternalStoragePref.setChecked(false); - } else { - FsSettings.setExternalStorageUri(treeUri.toString()); + private void setPermissionToUseExternalStorage(Uri treeUri) { + final CheckBoxPreference writeExternalStoragePref = findPref("writeExternalStorage"); + if (isNotExternalStorage(treeUri)) { + writeExternalStoragePref.setChecked(false); + } else { + try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - getActivity().getContentResolver() - .takePersistableUriPermission(treeUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (removeAllUriPermissions(treeUri)) { + FsSettings.setExternalStorageUri(treeUri.toString()); + getActivity().getContentResolver() + .takePersistableUriPermission(treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } } writeExternalStoragePref.setChecked(true); + } catch (SecurityException e) { + // Harden code against crash: May reach here by adding exact same picker location but + // being removed at same time. } } } + private boolean isNotExternalStorage(Uri treeUri) { + String folder = FileUtil.cleanupUriStoragePath(treeUri); + if (folder != null && folder.contains(":")) { + // Just get rid of the "primary:" part to get what we want (the user selected path/folder) + try { + folder = folder.substring(folder.indexOf(":") + 1); + } catch (IndexOutOfBoundsException e) { + folder = ""; + } + } + return folder == null || folder.isEmpty(); + } + + /* + * If user is on older SDK, check if File can rw and if not then move to newer storage use. + * As we don't know what older SDK will have a problem where or not. + * Could just assume with this use but a check is fast. + * */ + private void tryToUpgradeToScopedStorage(Uri treeUri) { + if (!Util.useScopedStorage()) { + DocumentFile df = FileUtil.getDocumentFileFromUri(treeUri); + if (df == null) return; + final String a11Path = FileUtil.getUriStoragePathFullFromDocumentFile(df, ""); + if (a11Path == null) return; + File root = new File(a11Path); + if (!root.canRead() || !root.canWrite()) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(App.getAppContext()); + sp.edit().putBoolean("OverrideScopedStorageMinimum", true).apply(); + } + } + } + + /* + * Override all and put path as chroot. Previously user chosen. On Android 11+ it is only + * decided now by the picker if its to be allowed on the Play Store unless allowance was made. + * */ + private void scopedStorageChrootOverride(Uri treeUri) { + if (Util.useScopedStorage()) { + DocumentFile df = FileUtil.getDocumentFileFromUri(treeUri); + if (df == null) return; + final String a11Path = FileUtil.getUriStoragePathFullFromDocumentFile(df, ""); + if (a11Path == null) return; + List userList = FsSettings.getUsers(); + for (int i = 0; i < userList.size(); i++) { + if (userList.get(i) == null) continue; + FtpUser entry = new FtpUser(userList.get(i).getUsername(),userList.get(i).getPassword(), a11Path); + FsSettings.modifyUser(userList.get(i).getUsername(), entry); + } + } + } + + /* + * Clean up URI list since there's only one folder. They have a way of collecting on changes + * which causes an issue. More so only can use one. + * */ + private boolean removeAllUriPermissions(Uri treeUri) { + List oldList = App.getAppContext().getContentResolver().getPersistedUriPermissions(); + if (oldList.size() == 0) return true; + // check against current and don't remove if only and same as it won't re-give same. + if (oldList.size() == 1) { + Uri uri = oldList.get(0).getUri(); + if (uri != null) { + final String path = uri.getPath(); + if (path != null) if (path.equals(treeUri.getPath())) return false; + } + } + // Release all + for (UriPermission uriToRemove : oldList) { + if (uriToRemove == null) continue; + getActivity().getContentResolver() + .releasePersistableUriPermission(uriToRemove.getUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + return true; + } + /** * Update the summary for the users. When there are no users, ask to add at least one user. * When there is one user, display helpful message about user/password. When there are diff --git a/app/src/main/java/be/ppareit/swiftp/gui/UserEditFragment.java b/app/src/main/java/be/ppareit/swiftp/gui/UserEditFragment.java index 2a4d1018..0fa6e3e3 100644 --- a/app/src/main/java/be/ppareit/swiftp/gui/UserEditFragment.java +++ b/app/src/main/java/be/ppareit/swiftp/gui/UserEditFragment.java @@ -3,10 +3,14 @@ import android.app.AlertDialog; import android.app.Fragment; +import android.content.SharedPreferences; import android.os.Bundle; import android.os.Environment; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import android.preference.PreferenceManager; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,8 +20,10 @@ import java.io.File; +import be.ppareit.swiftp.App; import be.ppareit.swiftp.FsSettings; import be.ppareit.swiftp.R; +import be.ppareit.swiftp.Util; import be.ppareit.swiftp.server.FtpUser; public class UserEditFragment extends Fragment { @@ -45,9 +51,19 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, chroot.setOnFocusChangeListener((v, hasFocus) -> { if (!hasFocus) return; - showFolderPicker(chroot); + if (Util.useScopedStorage()) { + alertUsersToScopedStorage(); + } else { + showFolderPicker(chroot); + } + }); + chroot.setOnClickListener(v -> { + if (Util.useScopedStorage()) { + alertUsersToScopedStorage(); + } else { + showFolderPicker(chroot); + } }); - chroot.setOnClickListener(view -> showFolderPicker(chroot)); if (item != null) { username.setText(item.getUsername()); @@ -81,10 +97,15 @@ private void showFolderPicker(TextView chrootView) { AlertDialog folderPicker = new FolderPickerDialogBuilder(getActivity(), startDir) .setSelectedButton(R.string.select, path -> { final File root = new File(path); - if (!root.canRead()) { - showToast(R.string.notice_cant_read_write); - } else if (!root.canWrite()) { - showToast(R.string.notice_cant_write); + if (!root.canRead() || !root.canWrite()) { + Log.e("pre", "using scoped..."); + alertUsersToScopedStorage(); + } else { + Log.e("pre", "FILE CAN READ & WRITE..."); + // Disable the storage minimum override if eg internal use has rw capability over sd card. + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(App.getAppContext()); + sp.edit().remove("OverrideScopedStorageMinimum").apply(); + Util.reGetStorageOverride(); } chrootView.setText(path); }) @@ -113,4 +134,12 @@ private void showToast(int errorResId) { interface OnEditFinishedListener { void onEditActionFinished(FtpUser oldItem, FtpUser newItem); } + + // Android 11+ must use the other picker only. Alert users to go there. + // eg Android 8.0 sd card may be required also determined by check above. + private void alertUsersToScopedStorage() { + final String s = getString(R.string.advanced_settings_label) + " > " + + getString(R.string.writeExternalStorage_label); + Toast.makeText(App.getAppContext(), s, Toast.LENGTH_SHORT).show(); + } } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractListing.java b/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractListing.java index 7f79ffde..c79d8bf7 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractListing.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractListing.java @@ -28,11 +28,15 @@ package be.ppareit.swiftp.server; import java.io.File; -import java.util.Arrays; import java.util.Comparator; import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + +import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; + public abstract class CmdAbstractListing extends FtpCmd { // TODO: .class.getSimpleName() from abstract class? private static String TAG = "CmdAbstractListing"; @@ -41,26 +45,74 @@ public CmdAbstractListing(SessionThread sessionThread, String input) { super(sessionThread); } - abstract String makeLsString(File file); + abstract String makeLsString(FileUtil.Gen gen); // Creates a directory listing by finding the contents of the directory, // calling makeLsString on each file, and concatenating the results. // Returns an error string if failure, returns null on success. May be // called by CmdLIST or CmdNLST, since they each override makeLsString // in a different way. - public String listDirectory(StringBuilder response, File dir) { - if (!dir.isDirectory()) { + public String listDirectory(StringBuilder response, FileUtil.Gen dir) { + if (dir == null || !dir.isDirectory()) { return "500 Internal error, listDirectory on non-directory\r\n"; } Log.d(TAG, "Listing directory: " + dir.toString()); + final Object o = dir.getOb(); + final boolean isScoped = Util.useScopedStorage() && o instanceof DocumentFile; + if (isScoped) return listEntriesScoped(response, (DocumentFile) o); + else return listEntriesScopedPre(response, (File) o); + } + + private String listEntriesScoped(StringBuilder response, DocumentFile dir) { + DocumentFile[] entries; + // Get a listing of all files and directories in the path + try { + entries = dir.listFiles(); // about 2 seconds for 1,700 files + } catch (NullPointerException e) { + return "500 Couldn't list directory. Check config and mount status.\r\n"; + } + // Removed sorting as its far too slow with DocumentFile and also not seeing a reason... + // May also be negatively affecting File also at times. + // Unknown what the original reason was for even using Arrays.sort() here anyway as + // the client applications do their own sorting eg FileZilla has its own sort and is sorted + // by name when it gets the list here without Arrays.sort(). + // Plus, FileZilla also times out and ends the listing at 20 seconds :) + // And also, sorting first using File then using DocumentFile.fromFile results in "File://" + // Uri which will only cause problems as it doesn't hold full use. + final int cpuThreadCount = Runtime.getRuntime().availableProcessors(); + if (entries.length < cpuThreadCount || cpuThreadCount == 1) { // Absolute minimum for threaded + buildResponseOldStyle(response, entries); + } else { + // On Android 11 with DocumentFile which is much slower than File... + // Seeing about 2,214 files max at 8 threads within FileZilla's 20 second delay timeout axe + // give or take depending on device core/thread amount and CPU performance. Plus ~2 second + // leftover. So, can do about 123 files per second with current code. + // Total backup time test of threads w/near similar backup execution & 600+ files to list: + // cpu #1: 3 min 59 seconds + // cpu #2: 2 min 58 seconds + // cpu #8: 1 min 53 seconds + // 2 minutes less with device of 8 CPU threads when using buildResponseThreaded(). + // Android 6 with File... + // 5,161 files being listed: + // Old non-threaded time: 9 seconds + // New threaded time: < 1 second + buildResponseThreaded(response, entries, cpuThreadCount); + } + + return null; + } + + private String listEntriesScopedPre(StringBuilder response, File dir) { // Get a listing of all files and directories in the path File[] entries = dir.listFiles(); if (entries == null) { return "500 Couldn't list directory. Check config and mount status.\r\n"; } Log.d(TAG, "Dir len " + entries.length); - try { + + // Commented for performance. Not seeing any negative effect from not using. Client should do this anyway. +/* try { Arrays.sort(entries, listingComparator); } catch (Exception e) { // once got a FC on this, seems it is possible to have a dir that @@ -69,13 +121,154 @@ public String listDirectory(StringBuilder response, File dir) { // play for sure, and get back the entries entries = dir.listFiles(); } + + if (entries == null) { + return "500 Couldn't list directory. Check config and mount status.\r\n"; + }*/ + + final int cpuThreadCount = Runtime.getRuntime().availableProcessors(); + if (entries.length < cpuThreadCount) { // use cpu count as an absolute minimum + buildResponseOldStyle(response, entries); + } else { + buildResponseThreaded(response, entries, cpuThreadCount); + } + + return null; + } + + private void buildResponseOldStyle(StringBuilder response, DocumentFile[] entries) { + for (DocumentFile entry : entries) { + String curLine = makeLsString(new FileUtil.Gen(entry)); + if (curLine != null) { + response.append(curLine); + } + } + } + + private void buildResponseOldStyle(StringBuilder response, File[] entries) { for (File entry : entries) { - String curLine = makeLsString(entry); + String curLine = makeLsString(new FileUtil.Gen(entry)); if (curLine != null) { response.append(curLine); } } - return null; + } + + private void buildResponseThreaded(StringBuilder response, DocumentFile[] entries, final int cpuThreadCount) { + // Prep for threading per core/thread count + // More than 2 threads should only help more as the entries count increase + Thread[] threads = new Thread[cpuThreadCount]; + StringBuilder[] responses = new StringBuilder[cpuThreadCount]; + final int totalCount = entries.length; + final int splitCount = totalCount / cpuThreadCount; + int[] startLoc = new int[cpuThreadCount]; + int[] endLoc = new int[cpuThreadCount]; // eg 270 is total count 271 since starting at 0 + for (int i = 0; i < cpuThreadCount; i++) { + startLoc[i] = splitCount * i; + if (i > 0) startLoc[i]++; // add one so they don't start where the other ends + endLoc[i] = i == 0 ? splitCount : splitCount * (i + 1); + if (i == cpuThreadCount - 1) { + if (endLoc[i] != totalCount - 1) + endLoc[i] = totalCount - 1; // sometimes off + } + responses[i] = new StringBuilder(); + } + + // Split off onto the cores/threads of the device + for (int i = 0; i < cpuThreadCount; i++) { + threads[i] = dynamicThreadSplitter(entries, responses, i, startLoc, endLoc); + threads[i].setPriority(Thread.MIN_PRIORITY); + threads[i].start(); + } + + // Need to wait until all are finished starting with first running to last running + dynamicThreadJoiner(threads); + + // Need to include results from first to last to keep them in the proper order + response.append(dynamicResponseJoiner(responses)); + } + + private void buildResponseThreaded(StringBuilder response, File[] entries, final int cpuThreadCount) { + // Prep for threading per core/thread count + // More than 2 threads should only help more as the entries count increase + Thread[] threads = new Thread[cpuThreadCount]; + StringBuilder[] responses = new StringBuilder[cpuThreadCount]; + final int totalCount = entries.length; + final int splitCount = totalCount / cpuThreadCount; + int[] startLoc = new int[cpuThreadCount]; + int[] endLoc = new int[cpuThreadCount]; // eg 270 is total count 271 since starting at 0 + for (int i = 0; i < cpuThreadCount; i++) { + startLoc[i] = splitCount * i; + if (i > 0) startLoc[i]++; // add one so they don't start where the other ends + endLoc[i] = i == 0 ? splitCount : splitCount * (i + 1); + if (i == cpuThreadCount - 1) { + if (endLoc[i] != totalCount - 1) + endLoc[i] = totalCount - 1; // sometimes off + } + responses[i] = new StringBuilder(); + } + + // Split off onto the cores/threads of the device for more performance here + for (int i = 0; i < cpuThreadCount; i++) { + threads[i] = dynamicThreadSplitter(entries, responses, i, startLoc, endLoc); + threads[i].start(); + } + + // Need to wait until all are finished starting with first running to last running + dynamicThreadJoiner(threads); + + // Need to include results from first to last to keep them in the proper order + response.append(dynamicResponseJoiner(responses)); + } + + /* + * Eg if device has 3 threads and there are 180 entries (aka files): + * 60 * 3 = 180 + * So 0-60 thread 1, 61-120 thread 2, 121-180 thread 3 (push down 1 w/array) + * (or another example eg 271 / 2 = 0-135 for thread 1 and 136-270 for thread 2) + * */ + private Thread dynamicThreadSplitter(DocumentFile[] entries, StringBuilder[] response, final int loc, + int[] startLoc, int[] endLoc) { + return new Thread(() -> { + for (int i = startLoc[loc]; i <= endLoc[loc]; i++) { // Needs to start at next of prev thread + String curLine = makeLsString(new FileUtil.Gen(entries[i])); + if (curLine != null) { + response[loc].append(curLine); + } + } + }); + } + + private Thread dynamicThreadSplitter(File[] entries, StringBuilder[] response, final int loc, int[] startLoc, + int[] endLoc) { + return new Thread(() -> { + for (int i = startLoc[loc]; i <= endLoc[loc]; i++) { // Needs to start at next of prev thread + String curLine = makeLsString(new FileUtil.Gen(entries[i])); + if (curLine != null) { + response[loc].append(curLine); + } + } + }); + } + + private void dynamicThreadJoiner(Thread[] t) { + for (Thread thread : t) { + try { + thread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + // just continue marching on + } + } + } + + private StringBuilder dynamicResponseJoiner(StringBuilder[] sb) { + StringBuilder result = new StringBuilder(); + for (StringBuilder each : sb) { + result.append(each); + } + return result; } // Send the directory listing over the data socket. Used by CmdLIST and CmdNLST. diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java b/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java index 8ced03eb..4483a4d7 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java @@ -24,7 +24,7 @@ * the common code is in this class, and inherited by CmdSTOR and CmdAPPE. */ -import android.util.Log; +import androidx.documentfile.provider.DocumentFile; import net.vrallev.android.cat.Cat; @@ -32,8 +32,10 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; import be.ppareit.swiftp.App; +import be.ppareit.swiftp.Util; import be.ppareit.swiftp.utils.FileUtil; import be.ppareit.swiftp.MediaUpdater; @@ -46,11 +48,12 @@ public CmdAbstractStore(SessionThread sessionThread, String input) { public void doStorOrAppe(String param, boolean append) { Cat.d("STOR/APPE executing with append = " + append); - File storeFile = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); - + File storeFile = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); + DocumentFile docStoreFile = null; String errString = null; FileOutputStream out = null; - + OutputStream os = null; storing: { // Get a normalized absolute path for the desired file @@ -75,26 +78,50 @@ public void doStorOrAppe(String param, boolean append) { break storing; } // Notify other apps that we just deleted a file - MediaUpdater.notifyFileDeleted(storeFile.getPath()); + if (!Util.useScopedStorage()) { + // don't allow on Android 11+ as it causes problems + MediaUpdater.notifyFileDeleted(storeFile.getPath()); + } } } + if (!storeFile.exists()) { - FileUtil.mkfile(storeFile, App.getAppContext()); + if (Util.useScopedStorage()) { + final String mime = "application/octet-stream"; + //final URI fileUri = storeFile.toURI(); + //final URL url = fileUri.toURL(); + //mime = url.openConnection().getContentType(); // sometimes makes exe's into html files + docStoreFile = FileUtil.mkfile(storeFile, App.getAppContext(), mime); + if(docStoreFile == null){ + errString = "451 Couldn't open file \"" + param + "\" aka \"" + + storeFile.getCanonicalPath() + "\" for writing\r\n"; + break storing; + } + } else { + FileUtil.mkfile(storeFile, App.getAppContext()); + } + } + + if (docStoreFile != null) { + os = App.getAppContext().getContentResolver().openOutputStream(docStoreFile.getUri()); + } else { + out = FileUtil.getOutputStream(storeFile, App.getAppContext()); } - out = FileUtil.getOutputStream(storeFile, App.getAppContext()); //file - if(out == null){ + if(docStoreFile == null && out == null){ errString = "451 Couldn't open file \"" + param + "\" aka \"" + storeFile.getCanonicalPath() + "\" for writing\r\n"; break storing; } try { - if (sessionThread.offset < 0) { - out.getChannel().position(storeFile.length()); - } else { - out.getChannel().position(sessionThread.offset); - sessionThread.offset = -1; + if (out != null) { + if (sessionThread.offset < 0) { + out.getChannel().position(storeFile.length()); + } else { + out.getChannel().position(sessionThread.offset); + sessionThread.offset = -1; + } } } catch (NullPointerException e){ @@ -143,13 +170,15 @@ public void doStorOrAppe(String param, boolean append) { default: try { if (sessionThread.isBinaryMode()) { - out.write(buffer, 0, numRead); + if (os != null) os.write(buffer, 0, numRead); + else out.write(buffer, 0, numRead); } else { // ASCII mode, substitute \r\n to \n int startPos = 0, endPos; for (endPos = 0; endPos < numRead; endPos++) { if (buffer[endPos] == '\r') { - out.write(buffer, startPos, endPos - startPos); + if (os != null) os.write(buffer, startPos, endPos - startPos); + else out.write(buffer, startPos, endPos - startPos); // Our hacky method is to drop all \r startPos = endPos + 1; } @@ -157,7 +186,8 @@ public void doStorOrAppe(String param, boolean append) { // Write last part of buffer as long as there was something // left after handling the last \r if (startPos < numRead) { - out.write(buffer, startPos, endPos - startPos); + if (os != null) os.write(buffer, startPos, endPos - startPos); + else if (out != null) out.write(buffer, startPos, endPos - startPos); } } } catch (IOException e) { @@ -173,6 +203,9 @@ public void doStorOrAppe(String param, boolean append) { if (out != null) { out.close(); } + if (os != null) { + os.close(); + } } catch (IOException ignored) { } @@ -183,7 +216,10 @@ public void doStorOrAppe(String param, boolean append) { sessionThread.writeString("226 Transmission complete\r\n"); // Notify the music player (and possibly others) that a few file has // been uploaded. - MediaUpdater.notifyFileCreated(storeFile.getPath()); + if (!Util.useScopedStorage()) { + // don't allow on Android 11+ as it causes problems + MediaUpdater.notifyFileCreated(storeFile.getPath()); + } } sessionThread.closeDataSocket(); Cat.d("STOR finished"); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java index ab5464cc..b4d24d45 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java @@ -41,7 +41,8 @@ public void run() { File newDir; String errString = null; mainblock: { - newDir = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + newDir = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, true); // Ensure the new path does not violate the chroot restriction if (violatesChroot(newDir)) { diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java b/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java index 7bc050c8..076adfae 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java @@ -23,7 +23,10 @@ import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import be.ppareit.swiftp.App; +import be.ppareit.swiftp.Util; import be.ppareit.swiftp.utils.FileUtil; import be.ppareit.swiftp.MediaUpdater; @@ -41,14 +44,38 @@ public CmdDELE(SessionThread sessionThread, String input) { public void run() { Log.d(TAG, "DELE executing"); String param = getParameter(input); - File storeFile = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + File storeFile = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); + + if (Util.useScopedStorage()) { + final String clientPath = storeFile.getPath().substring(0, storeFile.getPath() + .lastIndexOf(File.separator)); + DocumentFile docStoreFile = FileUtil.getDocumentFileWithParamScopedStorage(File.separator + + param, null, clientPath); + tryToDelete(new FileUtil.Gen(docStoreFile), clientPath); + return; + } + + tryToDelete(new FileUtil.Gen(storeFile), param); + } + + private void tryToDelete(FileUtil.Gen storeFile, String param) { String errString = null; - if (violatesChroot(storeFile)) { + if (storeFile == null || storeFile.getOb() == null) { errString = "550 Invalid name or chroot violation\r\n"; - } else if (storeFile.isDirectory()) { - errString = "550 Can't DELE a directory\r\n"; - } else if (!FileUtil.deleteFile(storeFile, App.getAppContext())) { - errString = "450 Error deleting file\r\n"; + } else { + final boolean isDocumentFile = storeFile.getOb() instanceof DocumentFile; + final boolean isFile = !isDocumentFile; + final String path = FileUtil.getScopedClientPath(param, null, null); + if ((isDocumentFile && violatesChroot((DocumentFile) storeFile.getOb(), path)) + || (isFile && violatesChroot((File) storeFile.getOb()))) { + errString = "550 Invalid name or chroot violation\r\n"; + } else if (storeFile.isDirectory()) { + errString = "550 Can't DELE a directory\r\n"; + } else if ((isDocumentFile && !((DocumentFile) storeFile.getOb()).delete()) + || (isFile && !FileUtil.deleteFile((File) storeFile.getOb(), App.getAppContext()))) { + errString = "450 Error deleting file\r\n"; + } } if (errString != null) { @@ -56,7 +83,10 @@ public void run() { Log.i(TAG, "DELE failed: " + errString.trim()); } else { sessionThread.writeString("250 File successfully deleted\r\n"); - MediaUpdater.notifyFileDeleted(storeFile.getPath()); + if (!Util.useScopedStorage()) { + // don't allow on Android 11+ as it causes problems + MediaUpdater.notifyFileDeleted(((File) storeFile.getOb()).getPath()); + } } Log.d(TAG, "DELE finished"); } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java b/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java index d7695b88..dc387b01 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java @@ -34,7 +34,8 @@ public void run() { mainblock: { - fileToHash = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + fileToHash = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); if (violatesChroot(fileToHash)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdLIST.java b/app/src/main/java/be/ppareit/swiftp/server/CmdLIST.java index c54ac7bd..a48ce4c6 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdLIST.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdLIST.java @@ -31,10 +31,15 @@ import java.util.Date; import java.util.Locale; -import android.util.Log; +import android.net.Uri; + +import androidx.documentfile.provider.DocumentFile; import net.vrallev.android.cat.Cat; +import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; + public class CmdLIST extends CmdAbstractListing implements Runnable { // The approximate number of milliseconds in 6 months @@ -49,6 +54,7 @@ public CmdLIST(SessionThread sessionThread, String input) { @Override public void run() { String errString = null; + DocumentFile docFileToList = null; mainblock: { String param = getParameter(input); @@ -61,27 +67,42 @@ public void run() { File fileToList = null; if (param.equals("")) { fileToList = sessionThread.getWorkingDir(); + if (Util.useScopedStorage()) { + final String clientPath = fileToList.getPath(); + Uri uri = FileUtil.getFullCWDUri("", clientPath); + docFileToList = FileUtil.getDocumentFileFromUri(uri); + } } else { if (param.contains("*")) { errString = "550 LIST does not support wildcards\r\n"; break mainblock; } fileToList = new File(sessionThread.getWorkingDir(), param); + if (Util.useScopedStorage()) { + final String clientPath = fileToList.getPath(); + docFileToList = FileUtil.getDocumentFileWithParamScopedStorage(param, "", clientPath); + } if (violatesChroot(fileToList)) { + // sd card should be eg /storage/xxx/ + // internal should be /storage/emulated/0/ errString = "450 Listing target violates chroot\r\n"; break mainblock; } } String listing; - if (fileToList.isDirectory()) { + if (fileToList.isDirectory() || (docFileToList != null && docFileToList.isDirectory())) { StringBuilder response = new StringBuilder(); - errString = listDirectory(response, fileToList); + FileUtil.Gen gen; + if (docFileToList != null) gen = FileUtil.convertDocumentFileToGen(docFileToList); + else gen = FileUtil.createGenFromFile(fileToList); + errString = listDirectory(response, gen); if (errString != null) { break mainblock; } listing = response.toString(); } else { - listing = makeLsString(fileToList); + if (docFileToList != null) listing = makeLsString(new FileUtil.Gen(docFileToList)); + else listing = makeLsString(new FileUtil.Gen(fileToList)); if (listing == null) { errString = "450 Couldn't list that file\r\n"; break mainblock; @@ -94,6 +115,8 @@ public void run() { } if (errString != null) { + // May see "error 450 couldn't list that file" from bad path handling and this would then + // be a bug and not an actual missing file. sessionThread.writeString(errString); Cat.d("LIST failed with: " + errString); } else { @@ -105,11 +128,10 @@ public void run() { // Generates a line of a directory listing in the traditional /bin/ls // format. - @Override - protected String makeLsString(File file) { + protected String makeLsString(FileUtil.Gen file) { StringBuilder response = new StringBuilder(); - if (!file.exists()) { + if (file == null || !file.exists()) { Cat.i("makeLsString had nonexistent file"); return null; } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java index 47358fb4..2e058cfb 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java @@ -45,7 +45,8 @@ public CmdMDTM(SessionThread sessionThread, String input) { public void run() { Log.d(TAG, "run: MDTM executing, input: " + mInput); String param = getParameter(mInput); - File file = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + File file = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); if (file.exists()) { long lastModified = file.lastModified(); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java index b8753505..b5233155 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java @@ -75,7 +75,7 @@ public void run() { } File file = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), pathName); + sessionThread.getWorkingDir(), pathName, false); if (!file.exists()) { sessionThread.writeString("550 file does not exist on server\r\n"); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java index 548b8330..29c102ea 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java @@ -50,7 +50,8 @@ public void run() { errString = "550 Invalid name\r\n"; break mainblock; } - toCreate = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + toCreate = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, true); if (violatesChroot(toCreate)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMLSD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMLSD.java index d4be4c2c..31192591 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMLSD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMLSD.java @@ -29,7 +29,7 @@ import java.io.File; import android.util.Log; -import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; public class CmdMLSD extends CmdAbstractListing implements Runnable { static private final String TAG = CmdMLSD.class.getSimpleName(); @@ -72,7 +72,12 @@ public void run() { // TBD // https://tools.ietf.org/html/rfc3659#page-39 // MLSD auto need to add [type=cdir] and [type=pdir] - errString = listDirectory(response, fileToList); + FileUtil.Gen gen = FileUtil.createGenFromFile(fileToList); + if (gen.getOb() == null) { + errString = "MLSD failed. Try write external storage\r\n"; + break mainblock; + } + errString = listDirectory(response, gen); if (errString != null) { break mainblock; } @@ -96,7 +101,7 @@ public void run() { // Generates a line of a directory listing in the Format of MLSx @Override - protected String makeLsString(File file) { + protected String makeLsString(FileUtil.Gen file) { StringBuilder response = new StringBuilder(); if (!file.exists()) { @@ -118,43 +123,8 @@ protected String makeLsString(File file) { // staticLog.l(Log.DEBUG, "Filename: " + lastNamePart); } - String[] selectedTypes = sessionThread.getFormatTypes(); - if(selectedTypes != null){ - for (int i = 0; i < selectedTypes.length; ++i) { - String type = selectedTypes[i]; - if (type.equalsIgnoreCase("size")) { - response.append("Size=" + String.valueOf(file.length()) + ';'); - } else if (type.equalsIgnoreCase("modify")) { - String timeStr = Util.getFtpDate(file.lastModified()); - response.append("Modify=" + timeStr + ';'); - } else if (type.equalsIgnoreCase("type")) { - if (file.isFile()) { - response.append("Type=file;"); - } else if (file.isDirectory()) { - response.append("Type=dir;"); - } - } else if (type.equalsIgnoreCase("perm")) { - response.append("Perm="); - if (file.canRead()) { - if (file.isFile()) { - response.append('r'); - } else if (file.isDirectory()) { - response.append("el"); - } - } - if (file.canWrite()) { - if (file.isFile()) { - response.append("adfw"); - } else if (file.isDirectory()) { - response.append("fpcm"); - } - } - response.append(';'); - } - } - } - - response.append(' '); + response.append(sessionThread.makeSelectedTypesResponse(file)); + response.append(' '); response.append(lastNamePart); response.append("\r\n"); return response.toString(); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java index c2fb9037..63a5998f 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java @@ -22,7 +22,7 @@ import java.io.File; import android.util.Log; -import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; /** * Implements MLST command @@ -41,18 +41,19 @@ public CmdMLST(SessionThread sessionThread, String input) { public void run() { Log.d(TAG, "run: LIST executing, input: " + mInput); String param = getParameter(mInput); - + File fileToFormat = null; if(param.equals("")){ fileToFormat = sessionThread.getWorkingDir(); param = "/"; }else{ - fileToFormat = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + fileToFormat = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); } - + if (fileToFormat.exists()) { sessionThread.writeString("250- Listing " + param + "\r\n"); - sessionThread.writeString(makeString(fileToFormat) + "\r\n"); + sessionThread.writeString(makeString(new FileUtil.Gen(fileToFormat)) + "\r\n"); sessionThread.writeString("250 End\r\n"); } else { Log.w(TAG, "run: file does not exist"); @@ -62,47 +63,11 @@ public void run() { Log.d(TAG, "run: LIST completed"); } - public String makeString(File file){ + public String makeString(FileUtil.Gen gen){ StringBuilder response = new StringBuilder(); - - String[] selectedTypes = sessionThread.getFormatTypes(); - if(selectedTypes != null){ - for (int i = 0; i < selectedTypes.length; ++i) { - String type = selectedTypes[i]; - if (type.equalsIgnoreCase("size")) { - response.append("Size=" + String.valueOf(file.length()) + ';'); - } else if (type.equalsIgnoreCase("modify")) { - String timeStr = Util.getFtpDate(file.lastModified()); - response.append("Modify=" + timeStr + ';'); - } else if (type.equalsIgnoreCase("type")) { - if (file.isFile()) { - response.append("Type=file;"); - } else if (file.isDirectory()) { - response.append("Type=dir;"); - } - } else if (type.equalsIgnoreCase("perm")) { - response.append("Perm="); - if (file.canRead()) { - if (file.isFile()) { - response.append('r'); - } else if (file.isDirectory()) { - response.append("el"); - } - } - if (file.canWrite()) { - if (file.isFile()) { - response.append("adfw"); - } else if (file.isDirectory()) { - response.append("fpcm"); - } - } - response.append(';'); - } - } - } - - response.append(' '); - response.append(file.getName()); + response.append(sessionThread.makeSelectedTypesResponse(gen)); + response.append(' '); + response.append(gen.getName()); response.append("\r\n"); return response.toString(); } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdNLST.java b/app/src/main/java/be/ppareit/swiftp/server/CmdNLST.java index f2a4d2e3..b28de123 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdNLST.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdNLST.java @@ -29,6 +29,7 @@ import java.io.File; import android.util.Log; +import be.ppareit.swiftp.utils.FileUtil; public class CmdNLST extends CmdAbstractListing implements Runnable { private static final String TAG = CmdNLST.class.getSimpleName(); @@ -75,13 +76,14 @@ public void run() { String listing; if (fileToList.isDirectory()) { StringBuilder response = new StringBuilder(); - errString = listDirectory(response, fileToList); + FileUtil.Gen gen = FileUtil.createGenFromFile(fileToList); + errString = listDirectory(response, gen); if (errString != null) { break mainblock; } listing = response.toString(); } else { - listing = makeLsString(fileToList); + listing = makeLsString(new FileUtil.Gen(fileToList)); if (listing == null) { errString = "450 Couldn't list that file\r\n"; break mainblock; @@ -104,7 +106,7 @@ public void run() { } @Override - protected String makeLsString(File file) { + protected String makeLsString(FileUtil.Gen file) { if (!file.exists()) { Log.i(TAG, "makeLsString had nonexistent file"); return null; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java index 45bc7ffa..0b112225 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java @@ -19,11 +19,17 @@ package be.ppareit.swiftp.server; +import android.net.Uri; import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import java.io.File; import java.io.IOException; +import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; + /** * PRINT WORKING DIRECTORY (PWD) * Command returns the working directory in the reply. @@ -45,13 +51,21 @@ public void run() { // user-visible path (inside the chroot directory). try { String currentDir = sessionThread.getWorkingDir().getCanonicalPath(); - File chrootDir = sessionThread.getChrootDir(); - if (chrootDir != null) { - currentDir = currentDir.substring(chrootDir.getCanonicalPath().length()); + if (Util.useScopedStorage()) { + Uri uri = FileUtil.getFullCWDUri("", currentDir); + DocumentFile df = null; + if (uri != null) df = FileUtil.getDocumentFileFromUri(uri); + final String path = FileUtil.getScopedClientPath(currentDir, null, null); + if (df != null) currentDir = FileUtil.getUriStoragePathFullFromDocumentFile(df, path); + } else { + File chrootDir = sessionThread.getChrootDir(); + if (chrootDir != null) { + currentDir = currentDir.substring(chrootDir.getCanonicalPath().length()); + } } // The root directory requires special handling to restore its // leading slash - if (currentDir.length() == 0) { + if (currentDir == null || currentDir.length() == 0) { currentDir = "/"; } sessionThread.writeString("257 \"" + currentDir + "\"\r\n"); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java index b5f35b27..41199de0 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java @@ -19,7 +19,9 @@ package be.ppareit.swiftp.server; -import android.util.Log; +import android.net.Uri; + +import androidx.documentfile.provider.DocumentFile; import net.vrallev.android.cat.Cat; @@ -27,6 +29,11 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; + +import be.ppareit.swiftp.App; +import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; public class CmdRETR extends FtpCmd implements Runnable { @@ -46,27 +53,35 @@ public void run() { mainblock: { - fileToRetr = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); - if (violatesChroot(fileToRetr)) { - errString = "550 Invalid name or chroot violation\r\n"; - break mainblock; - } else if (fileToRetr.isDirectory()) { - Cat.d("Ignoring RETR for directory"); - errString = "550 Can't RETR a directory\r\n"; - break mainblock; - } else if (!fileToRetr.exists()) { - Cat.d("Can't RETR nonexistent file: " + fileToRetr.getAbsolutePath()); - errString = "550 File does not exist\r\n"; - break mainblock; - } else if (!fileToRetr.canRead()) { - Cat.i("Failed RETR permission (canRead() is false)"); - errString = "550 No read permissions\r\n"; - break mainblock; + fileToRetr = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); + Uri uri; + + DocumentFile docFileToRetr = null; + if (Util.useScopedStorage()) { + final String clientPath = fileToRetr.getPath().substring(0, fileToRetr.getPath().lastIndexOf(File.separator)); + docFileToRetr = FileUtil.getDocumentFileWithParamScopedStorage(param, null, clientPath); + if (docFileToRetr == null) { + errString = "550 File does not exist\r\n"; + break mainblock; + } } + FileUtil.Gen gen; + if (docFileToRetr != null) gen = FileUtil.convertDocumentFileToGen(docFileToRetr); + else gen = FileUtil.convertFileToGen(fileToRetr); + if (validate(gen, param) != null) break mainblock; + FileInputStream in = null; + InputStream is = null; try { - in = new FileInputStream(fileToRetr); + if (Util.useScopedStorage()) { + if (docFileToRetr == null) break mainblock; + uri = docFileToRetr.getUri(); + is = App.getAppContext().getContentResolver().openInputStream(uri); + } else { + in = new FileInputStream(fileToRetr); + } byte[] buffer = new byte[SessionThread.DATA_CHUNK_SIZE]; int bytesRead; if (sessionThread.openDataSocket()) { @@ -80,7 +95,8 @@ public void run() { if (sessionThread.isBinaryMode()) { // RANG is supported only in binary mode. Cat.d("Transferring in binary mode"); long offset = 0L; - long endPosition = fileToRetr.length() - 1; + long endPosition = (Util.useScopedStorage() ? docFileToRetr.length() - 1 + : fileToRetr.length() - 1); if (sessionThread.offset >= 0) { offset = sessionThread.offset; if (sessionThread.endPosition >= offset) { @@ -90,8 +106,9 @@ public void run() { } // This is not a range but length (Range 0-0 would still read 0th byte), so +1 long bytesToRead = endPosition - offset + 1; - in.skip(offset); - while ((bytesRead = in.read(buffer)) != -1) { + if (Util.useScopedStorage()) is.skip(offset); + else in.skip(offset); + while ((bytesRead = (Util.useScopedStorage() ? is.read(buffer) : in.read(buffer))) != -1) { boolean success; if (bytesRead > bytesToRead) { success = sessionThread.sendViaDataSocket(buffer, 0, (int) bytesToRead); @@ -115,7 +132,7 @@ public void run() { } // We have to convert all solitary \n to \r\n boolean lastBufEndedWithCR = false; - while ((bytesRead = in.read(buffer)) != -1) { + while ((bytesRead = (Util.useScopedStorage() ? is.read(buffer) : in.read(buffer))) != -1) { int startPos = 0, endPos = 0; byte[] crnBuf = {'\r', '\n'}; for (endPos = 0; endPos < bytesRead; endPos++) { @@ -159,6 +176,8 @@ public void run() { try { if (in != null) in.close(); + if (is != null) + is.close(); } catch (IOException ignored) { } } @@ -171,4 +190,40 @@ public void run() { } Cat.d("RETR done"); } + + private String validate(FileUtil.Gen fileToRetr, String param) { + String errString = null; + if (fileToRetr == null) { + errString = "550 Invalid name or chroot violation\r\n"; + return errString; + } + + final boolean isDocumentFile = fileToRetr.getOb() instanceof DocumentFile; + final boolean isFile = !isDocumentFile; + + String clientPath; + String path = null; + if (isDocumentFile) { + clientPath = param; + if (!param.contains(File.separator)) clientPath = ""; + else if (!param.endsWith(File.separator)) clientPath = param.substring(0, param + .lastIndexOf(File.separator)); + path = FileUtil.getScopedClientPath(clientPath, null, null); + } + if ((isDocumentFile && violatesChroot((DocumentFile) fileToRetr.getOb(), path)) + || (isFile && violatesChroot((File) fileToRetr.getOb()))) { + errString = "550 Invalid name or chroot violation\r\n"; + } else if (fileToRetr.isDirectory()) { + Cat.d("Ignoring RETR for directory"); + errString = "550 Can't RETR a directory\r\n"; + } else if (!fileToRetr.exists()) { + if (isDocumentFile) Cat.d("Can't RETR nonexistent file: " + fileToRetr.getName()); + else Cat.d("Can't RETR nonexistent file: " + ((File)fileToRetr.getOb()).getAbsolutePath()); + errString = "550 File does not exist\r\n"; + } else if (!fileToRetr.canRead()) { + Cat.i("Failed RETR permission (canRead() is false)"); + errString = "550 No read permissions\r\n"; + } + return errString; + } } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java index de00eba2..c32d0754 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java @@ -23,8 +23,11 @@ import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import be.ppareit.swiftp.App; import be.ppareit.swiftp.MediaUpdater; +import be.ppareit.swiftp.Util; import be.ppareit.swiftp.utils.FileUtil; public class CmdRMD extends FtpCmd implements Runnable { @@ -43,13 +46,52 @@ public void run() { String param = getParameter(input); File toRemove; String errString = null; + + if (Util.useScopedStorage()) { + final String clientPath = sessionThread.getWorkingDir().getPath(); + DocumentFile docFileToRemove = FileUtil.getDocumentFileWithParamScopedStorage(param, + File.separator + param, clientPath); + + mainblock: + { + if (docFileToRemove == null){ + errString = "550 Invalid name or chroot violation\r\n"; + break mainblock; + } + if (param.length() < 1) { + errString = "550 Invalid argument\r\n"; + break mainblock; + } + if (violatesChroot(docFileToRemove, param)) { + errString = "550 Invalid name or chroot violation\r\n"; + break mainblock; + } + if (!docFileToRemove.isDirectory()) { + errString = "550 Can't RMD a non-directory\r\n"; + break mainblock; + } + if (!recursiveDelete(docFileToRemove)) { + errString = "550 Deletion error, possibly incomplete\r\n"; + } + } + if (errString != null) { + sessionThread.writeString(errString); + Log.i(TAG, "RMD failed: " + errString.trim()); + } else { + sessionThread.writeString("250 Removed directory\r\n"); + } + Log.d(TAG, "RMD finished"); + return; + } + mainblock: { if (param.length() < 1) { errString = "550 Invalid argument\r\n"; break mainblock; } - toRemove = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + toRemove = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); if (violatesChroot(toRemove)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; @@ -76,6 +118,31 @@ public void run() { Log.d(TAG, "RMD finished"); } + /** + * Accepts a file or directory name, and recursively deletes the contents of that + * directory and all subdirectories. + * + * @param toDelete + * @return Whether the operation completed successfully + */ + protected boolean recursiveDelete(DocumentFile toDelete) { + if (!toDelete.exists()) { + return false; + } + if (toDelete.isDirectory()) { + // If any of the recursive operations fail, then we return false + boolean success = true; + for (DocumentFile entry : toDelete.listFiles()) { + success &= recursiveDelete(entry); + } + Log.d(TAG, "Recursively deleted: " + toDelete); + return success && toDelete.delete(); + } else { + Log.d(TAG, "RMD deleting file: " + toDelete); + return toDelete.delete(); + } + } + /** * Accepts a file or directory name, and recursively deletes the contents of that * directory and all subdirectories. @@ -98,7 +165,10 @@ protected boolean recursiveDelete(File toDelete) { } else { Log.d(TAG, "RMD deleting file: " + toDelete); boolean success = FileUtil.deleteFile(toDelete, App.getAppContext()); - MediaUpdater.notifyFileDeleted(toDelete.getPath()); + if (!Util.useScopedStorage()) { + // Don't allow on Android 11+ as it causes problems should it reach here + MediaUpdater.notifyFileDeleted(toDelete.getPath()); + } return success; } } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java index 09a6d236..8908b2e0 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java @@ -48,7 +48,8 @@ public void run() { File file = null; mainblock: { - file = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + file = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); if (violatesChroot(file)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java index 136a04fd..bc553e38 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java @@ -54,7 +54,8 @@ public void run() { File toFile = null; mainblock: { - toFile = inputPathToChrootedFile(sessionThread.getChrootDir(), sessionThread.getWorkingDir(), param); + toFile = inputPathToChrootedFile(sessionThread.getChrootDir(), + sessionThread.getWorkingDir(), param, false); Cat.i("RNTO to file: " + toFile.getPath()); if (violatesChroot(toFile)) { errString = "550 Invalid name or chroot violation\r\n"; diff --git a/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java b/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java index 2fe17c14..91f368fd 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java +++ b/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java @@ -19,11 +19,17 @@ package be.ppareit.swiftp.server; +import android.net.Uri; import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import java.io.File; import java.lang.reflect.Constructor; +import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; + public abstract class FtpCmd implements Runnable { private static final String TAG = FtpCmd.class.getSimpleName(); @@ -183,7 +189,98 @@ static public String getParameter(String input) { return getParameter(input, false); } - public static File inputPathToChrootedFile(File chrootDir, File existingPrefix, String param) { + public static File inputPathToChrootedFile(final File chrootDir, final File existingPrefix, + String param, final boolean isDirOnly) { + if (Util.useScopedStorage()) { + // NEW WAY + // Chroot is *1 tree & including eg *2 "/storage" that the tree doesn't contain. + // *3 The rest is provided by the client. + // *4 The param when with file is with *3 so we need to remove path from param when it is. + // *5 All that's left is to tack on the client provided path to the end of the tree. + // There are multiple conflicting oddities that happen and are dealt with here. + if (isDirOnly && !param.startsWith(File.separator)) param = File.separator + param; + // May be empty at times and param will instead have it. + String sessionClientPath = FileUtil.getScopedClientPath(param, existingPrefix, null); + // Get the full chroot path including storage. + Uri uri = FileUtil.getTreeUri(); + DocumentFile df = FileUtil.getDocumentFileFromUri(uri); + final String tree = FileUtil.getUriStoragePathFullFromDocumentFile(df, ""); + if (tree == null) return new File(""); // That's bad. Make following checks fail. + // Deal with param and client specified paths. + String paramClientPath = ""; + if (!isDirOnly && param.contains(File.separator)) { + final int lastSlash = param.lastIndexOf(File.separator); + paramClientPath = param.substring(0, lastSlash); + // Can't have it in there with the new code. That's a conflict! + param = param.substring(lastSlash + 1); + if (!paramClientPath.startsWith(File.separator)) { + // Keep it the same to avoid problems. + paramClientPath = File.separator + paramClientPath; + } + } else if (isDirOnly && param.contains(File.separator)) { + // To make things worse, the param can also be a full path such as in FileZilla and + // using its tree to jump randomly anywhere. Here, the param already contains the + // entire path needed. And then sometimes it doesn't have the entire path lol :) + if (param.contains(tree)) { + return new File(param); + } + } + String mPath = ""; + // To make it worse, param can be dir and have no file and no slash lol. So... + // added isDirOnly in order to know what the calling method is working on as that can tell us. + if (isDirOnly) { + if (!sessionClientPath.isEmpty() && !sessionClientPath.equals(param)) { + // Varoious fixes and checks of random ways it can do as seen on one device and internal + if (param.startsWith(File.separator) && !tree.startsWith(File.separator)) { + param = param.replaceFirst(File.separator, ""); + } else if (!param.startsWith(File.separator) && tree.startsWith(File.separator)) { + param = File.separator + param; + } + if (param.endsWith(File.separator) && !tree.endsWith(File.separator)) { + param = param.substring(0, param.length() - 1); + } else if (!param.endsWith(File.separator) && tree.endsWith(File.separator)) { + param += File.separator; + } + if (!param.equals(tree)) { + mPath = sessionClientPath + File.separator + param; + } + } else { + mPath = param; + } + } else { + if (!sessionClientPath.isEmpty() && paramClientPath.isEmpty()) + mPath = sessionClientPath; + else if (sessionClientPath.isEmpty() && !paramClientPath.isEmpty()) + mPath = paramClientPath; + else { + if (sessionClientPath.equals(paramClientPath)) mPath = sessionClientPath; + else mPath = paramClientPath; + } + } + // Various checks and fixes as to ways it could go as seen on one device and internal + if (!mPath.startsWith(File.separator) && !tree.endsWith(File.separator)) { + mPath = File.separator + mPath; + } else if (mPath.startsWith(File.separator) && tree.endsWith(File.separator)) { + mPath = mPath.replaceFirst(File.separator, ""); + } + // Finally get the full current path + String path = ""; + if (!mPath.contains(tree)) path = tree + mPath; + // Let's end this. + if (!isDirOnly){ + return new File(path, param); + } + if (sessionClientPath.equals(param)) { + final String doubleVision = sessionClientPath + param; + if (!path.endsWith(doubleVision)) { + // Correct double vision; permutation fix + return new File(path, param); + } + } + // Another possible end + return new File(path); + } + // OLD WAY (with issues and not compatible with the new way.) try { if (param.charAt(0) == '/') { // The STOR contained an absolute path @@ -212,7 +309,29 @@ public boolean violatesChroot(File file) { return false; } catch (Exception e) { Log.i(TAG, "Path canonicalization problem: " + e.toString()); - Log.i(TAG, "When checking file: " + file.getAbsolutePath()); + if (file != null) Log.i(TAG, "When checking file: " + file.getAbsolutePath()); // fix possible crash + return true; // for security, assume violation + } + } + + public boolean violatesChroot(DocumentFile file, String param) { + try { + // Get the full path to the chosen Android 11 dir and compare with that of the file + File chroot = sessionThread.getChrootDir(); + String canonicalChroot = chroot.getCanonicalPath(); + final String path = FileUtil.getScopedClientPath(param, null, null); + String canonicalPath = FileUtil.getUriStoragePathFullFromDocumentFile(file, path); + + if (canonicalPath == null || !canonicalPath.startsWith(canonicalChroot)) { + Log.i(TAG, "Path violated folder restriction, denying"); + Log.d(TAG, "path: " + canonicalPath); + Log.d(TAG, "chroot: " + chroot.toString()); + return true; // the path must begin with the chroot path + } + return false; + } catch (Exception e) { + Log.i(TAG, "Path canonicalization problem: " + e.toString()); + //Log.i(TAG, "When checking file: " + file.getAbsolutePath()); return true; // for security, assume violation } } diff --git a/app/src/main/java/be/ppareit/swiftp/server/SessionThread.java b/app/src/main/java/be/ppareit/swiftp/server/SessionThread.java index 7a485ff8..e648c822 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/SessionThread.java +++ b/app/src/main/java/be/ppareit/swiftp/server/SessionThread.java @@ -35,12 +35,16 @@ import be.ppareit.swiftp.App; import be.ppareit.swiftp.FsSettings; +import be.ppareit.swiftp.Util; +import be.ppareit.swiftp.utils.FileUtil; public class SessionThread extends Thread { private static final int MAX_AUTH_FAILS = 3; public static final int DATA_CHUNK_SIZE = 65536; // do file I/O in 64k chunks + private static boolean[] selectedTypesCached = null; + private Socket cmdSocket; private boolean pasvMode = false; private boolean binaryMode = false; @@ -419,4 +423,66 @@ public void setChrootDir(String chrootPath) { this.workingDir = chrootDir; } } + + public String makeSelectedTypesResponse(FileUtil.Gen gen) { + StringBuilder response = new StringBuilder(); + String[] selectedTypes = getFormatTypes(); + + if (selectedTypesCached == null && selectedTypes != null) { + selectedTypesCached = new boolean[formatTypes.length]; + for (String selectedType : selectedTypes) { + switch (selectedType) { + case "Size": + selectedTypesCached[0] = true; + break; + case "Modify": + selectedTypesCached[1] = true; + break; + case "Type": + selectedTypesCached[2] = true; + break; + case "Perm": + selectedTypesCached[3] = true; + break; + } + } + } + + final boolean isFile = gen.isFile(); + final boolean isDirectory = gen.isDirectory(); + + if (selectedTypesCached[0]) { + response.append("Size=").append(gen.length()).append(';'); + } + if (selectedTypesCached[1]) { + String timeStr = Util.getFtpDate(gen.lastModified()); + response.append("Modify=").append(timeStr).append(';'); + } + if (selectedTypesCached[2]) { + if (isFile) { + response.append("Type=file;"); + } else if (isDirectory) { + response.append("Type=dir;"); + } + } + if (selectedTypesCached[3]) { + response.append("Perm="); + if (gen.canRead()) { + if (isFile) { + response.append('r'); + } else if (isDirectory) { + response.append("el"); + } + } + if (gen.canWrite()) { + if (isFile) { + response.append("adfw"); + } else if (isDirectory) { + response.append("fpcm"); + } + } + response.append(';'); + } + return response.toString(); + } } diff --git a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java index 1e4c0408..d2e9ed1d 100644 --- a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java +++ b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java @@ -4,16 +4,22 @@ import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; +import android.content.UriPermission; +import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.os.ParcelFileDescriptor; import android.provider.DocumentsContract; import android.provider.MediaStore; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.documentfile.provider.DocumentFile; import android.util.Log; +import android.util.Pair; +import org.jetbrains.annotations.Nullable; + import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -22,15 +28,23 @@ import java.io.OutputStream; import java.nio.channels.FileChannel; import java.util.ArrayList; +import java.util.LinkedList; import java.util.List; +import be.ppareit.swiftp.App; import be.ppareit.swiftp.FsSettings; +import be.ppareit.swiftp.Util; public abstract class FileUtil { private static final String LOG = "FileUtil"; + public enum TYPE { + file, + dir + } + /** * Copy a file. The target file may even be on external SD card for Kitkat. * @@ -121,7 +135,8 @@ public static FileOutputStream getOutputStream(final File target, Context contex if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Storage Access Framework DocumentFile targetDocument = getDocumentFile(target, false, context); - outStream = new ParcelFileDescriptor.AutoCloseOutputStream(context.getContentResolver().openFileDescriptor(targetDocument.getUri(), "rw")); + outStream = new ParcelFileDescriptor.AutoCloseOutputStream(context.getContentResolver() + .openFileDescriptor(targetDocument.getUri(), "rw")); } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { // Workaround for Kitkat ext SD card return MediaStoreHack.getOutputStream(context, target.getPath()); @@ -244,7 +259,8 @@ public static boolean renameFolder(@NonNull final File source, @NonNull final Fi return false; } try { - if(DocumentsContract.renameDocument(context.getContentResolver(), document.getUri(), target.getName()) != null) { + if((DocumentsContract.renameDocument(context.getContentResolver(), document.getUri(), + target.getName()) != null)) { return true; } } catch (FileNotFoundException e) { @@ -373,6 +389,25 @@ public static boolean mkfile(final File file, Context context) throws IOExceptio return false; } + // Performance improvement with DocumentFile. As we can avoid a finding of the file this way since + // create will far more quickly create it and give it along with that. Thus, needed a way to return + // DocumentFile at creation time. + public static DocumentFile mkfile(final File file, Context context, String mime) throws IOException { + try { + final String filename = file.getName(); + // Travel only to the dir for where the file goes: + Uri uri = getFullCWDUri("", file.getPath().substring(0, file.getPath().lastIndexOf(File.separator))); + // Can't create a file in dirs that have eg the symbol "?". + // Can create a folder with it. + // Can even create a file in a dirs that have # symbol. + DocumentFile dfile = FileUtil.getDocumentFileFromUri(uri).createFile(mime, filename); + if (dfile != null) return dfile; + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + /** * Delete a folder. * @@ -573,12 +608,88 @@ public static String[] getExtSdCardPathsForActivity(Context context) { * null is returned. */ @TargetApi(Build.VERSION_CODES.KITKAT) - private static String getExtSdCardFolder(final File file, Context context) { + private static String getExtSdCardFolder(final FileUtil.Gen file, Context context) { + boolean isDocFile = file.getOb() instanceof DocumentFile; // double check + if (Util.useScopedStorage() && isDocFile) { + return getExtSdCardFolderScopedStorage(file); + } else { + return getExtSdCardFolderOld(file, context); + } + } + + public static String getExtSdCardFolderScopedStorage(final FileUtil.Gen file) { + try { + String s = file.getCanonicalPath(); + String base = getSdCardBaseFolderScopedStorage(); + String name = getSdCardNameScopedStorage(); + if (name != null && !name.isEmpty() && s.contains(name)) { + String base2 = getTreeUriStoragePath((DocumentFile) file.getOb()); + if (base2 != null) { + if (base2.contains(File.separator)) { + base2 = base2.substring(0, base2.lastIndexOf(File.separator)); + } + base2 = base2.substring(0, base2.indexOf(name) + name.length()); + // need to return eg "/storage/sd card" + return base + File.separator + base2; + } + } + } catch (IOException e) { + // + } + return null; + } + + /* + * Used to get eg "/storage" since URIs for whatever reasons omit this. + * */ + public static String getSdCardBaseFolderScopedStorage() { + final File[] f = ContextCompat.getExternalFilesDirs(App.getAppContext(), null); + String path = null; + for (File each : f) { + final String eachPath = each.getPath(); + final Uri uri = FileUtil.getTreeUri(); + if (uri == null) return null; + String s = cleanupUriStoragePath(uri); + try { + // Only want the sd card folder + if (s != null) s = s.substring(0, s.indexOf(File.pathSeparator)); + } catch (Exception e) { + return null; + } + if (s != null) { + if (eachPath.contains(s)) { + // Found the sd card path eg "/storage/[sd card]/Temp" should return as "/storage" + // as the DocumentFile Uri will contain the [sd card] all the way to the picked folder. + path = eachPath.substring(0, eachPath.indexOf(s) - 1); + } + } + } + return path; + } + + /* + * Obtains only the sd card folder name or null if sd card is not being used + * */ + public static String getSdCardNameScopedStorage() { + final Uri uri = FileUtil.getTreeUri(); + if (uri == null) return null; + String s = cleanupUriStoragePath(uri); + if (s.contains("primary:")) return null; // It is definitely not on the sd card. + try { + // Only want the sd card folder name + s = s.substring(0, s.indexOf(File.pathSeparator)); + return s; + } catch (Exception e) { + return null; + } + } + + private static String getExtSdCardFolderOld(final FileUtil.Gen file, Context context) { String[] extSdPaths = getExtSdCardPaths(context); try { for (int i = 0; i < extSdPaths.length; i++) { if (file.getCanonicalPath().startsWith(extSdPaths[i])) { - return extSdPaths[i]; + return extSdPaths[i]; // returns eg "/storage/42F3-5677" } } } catch (IOException e) { @@ -595,7 +706,7 @@ private static String getExtSdCardFolder(final File file, Context context) { */ @TargetApi(Build.VERSION_CODES.KITKAT) public static boolean isOnExtSdCard(final File file, Context c) { - return getExtSdCardFolder(file, c) != null; + return getExtSdCardFolder(new Gen<>(file), c) != null; } /** @@ -608,24 +719,18 @@ public static boolean isOnExtSdCard(final File file, Context c) { */ @TargetApi(Build.VERSION_CODES.LOLLIPOP) public static DocumentFile getDocumentFile(final File file, final boolean isDirectory, Context context) { - String baseFolder = getExtSdCardFolder(file, context); + String baseFolder = getExtSdCardFolder(new Gen<>(file), context); boolean originalDirectory = false; + String relativePath; if (baseFolder == null) { return null; } - String relativePath = null; - try { - String fullPath = file.getCanonicalPath(); - if (!baseFolder.equals(fullPath)) - relativePath = fullPath.substring(baseFolder.length() + 1); - else originalDirectory = true; - } catch (IOException e) { - return null; - } catch (Exception f) { - originalDirectory = true; - //continue - } + Pair pair = getRelativePath(context, file, baseFolder); + if (pair == null) return null; + relativePath = pair.first; + originalDirectory = pair.second; + String as = FsSettings.getExternalStorageUri(); Uri treeUri = null; @@ -642,7 +747,7 @@ public static DocumentFile getDocumentFile(final File file, final boolean isDire } // start with root of SD card and then parse through document tree. - DocumentFile document = DocumentFile.fromTreeUri(context, treeUri); + DocumentFile document = FileUtil.getDocumentFileFromUri(treeUri); if (originalDirectory) return document; String[] parts = relativePath.split("\\/"); for (int i = 0; i < parts.length; i++) { @@ -650,7 +755,11 @@ public static DocumentFile getDocumentFile(final File file, final boolean isDire if (nextDocument == null) { if ((i < parts.length - 1) || isDirectory) { - nextDocument = document.createDirectory(parts[i]); + if (!parts[i].isEmpty()) { + nextDocument = document.createDirectory(parts[i]); + } else { + continue; // quick fix for an empty part causing major problems. + } } else { nextDocument = document.createFile(DocumentsContract.Document.COLUMN_MIME_TYPE, parts[i]); } @@ -661,6 +770,100 @@ public static DocumentFile getDocumentFile(final File file, final boolean isDire return document; } + private static Pair getRelativePath(Context context, File file, String baseFolder) { + String relativePath = ""; + boolean originalDirectory = false; + if (Util.useScopedStorage()) { + // Currently, relativePath needs to be "/file" for file or "" if no file is in + // fullPath for the following code to work right. + try { + Uri uri = getTreeUri(); + if (uri != null) { + DocumentFile dir = DocumentFile.fromSingleUri(context, uri); + if (dir != null) { + final String fullPath = file.getCanonicalPath(); + String clientPath = getScopedClientPath(file.getPath(), null, null); + final String DocumentFilePath = getUriStoragePathFullFromDocumentFile(dir, clientPath); + if (DocumentFilePath != null) { + relativePath = fullPath.replace(DocumentFilePath, ""); + } + } + } + } catch (IOException e) { + return null; + } catch (Exception f) { + originalDirectory = true; + //continue + } + } else { + // This following code isn't working right on Android 8.0 sd card use. Bypassed with + // override of Android 11+ new storage use which has tested successfully as backward + // compatible with 8.0 sd card. + try { + String fullPath = file.getCanonicalPath(); + if (!baseFolder.equals(fullPath)) + relativePath = fullPath.substring(baseFolder.length() + 1); + else originalDirectory = true; + } catch (IOException e) { + return null; + } catch (Exception f) { + originalDirectory = true; + //continue + } + } + return new Pair<>(relativePath, originalDirectory); + } + + /* + * The param is tangled with the paths at times so that is necessary to include at specific times. + * The meaning can change depending on slash or no slash since the param contains files or just dirs. + * */ + public static DocumentFile getDocumentFileWithParamScopedStorage(String param, @Nullable String cwd, String clientPath) { + DocumentFile f = null; + // affected by param having slash or not; empty or not + Uri uri = FileUtil.getFullCWDUri(cwd != null ? cwd : param, clientPath); + if (uri != null) { + try { + // Get the file + String mParam = param; + if (param.contains(File.separator)) { + try { + mParam = param.substring(param.lastIndexOf(File.separator) + 1); + } catch (NullPointerException e) { + mParam = null; + } + } + // param affects above but down there its file only: + f = FileUtil.customFindFile(uri, (mParam != null ? mParam : param), FileUtil.TYPE.file, clientPath); + if (f == null) + f = FileUtil.getDocumentFileFromUri(uri).findFile((mParam != null ? mParam : param)); + } catch (NullPointerException e) { + // + } + } + return f; + } + + /* + * Basic switch out of File to DocumentFile. Can't always be used as the File Uri isn't compatible + * with scoped storage. But, won't cause security failure for specific uses. + * */ + public static DocumentFile getDocumentFileFromFileScopedStorage(File f, String clientPath) { + if (Util.useScopedStorage()) { + Uri uri = FileUtil.getFullCWDUri(f.getPath(), clientPath); + if (uri != null) return FileUtil.getDocumentFileFromUri(uri); + } + return null; + } + + public static DocumentFile getDocumentFileFromUri(Uri uri) { + // For DocumentFile, to move into sub folders, the user path cannot start with / + // Say a user enters in "/test" inside the client, the string that is received will be "test". + // findFile("test") for getting the sub dir "test" contents works. + if (uri != null) return DocumentFile.fromTreeUri(App.getAppContext(), uri); + return null; + } + // Utility methods for Kitkat /** @@ -694,4 +897,358 @@ public static int checkFolder(final String f, Context context) { } return 0; } + + /* + * Retrieves the Uri required to use scoped storage. + * */ + public static Uri getTreeUri() { + // Does not change unless user uses the picker again + List list = App.getAppContext().getContentResolver().getPersistedUriPermissions(); + if (list.size() > 0 && list.get(0) != null) { + // Get the picker path and then tack on any user provided path in the client. + return list.get(0).getUri(); + } + return null; + } + + /* + * Uses DocumentFile.findFile() to get the Uri for the target dir but does it the slow way as + * findFile() has a major performance issue. + */ + public static Uri getFullCWDUriSlow(String param) { + Uri uri = getTreeUri(); + if (uri != null) { + try { + String s = getScopedClientPath(param, null, null); + if (s.isEmpty() && param.contains(File.separator)) { + s = param.substring(0, param.lastIndexOf(File.separator)); + } + String[] split = s.split(File.separator); + for (String value : split) { + if (value.isEmpty()) continue; + String part = value.replaceAll(File.separator, ""); + DocumentFile dFile = FileUtil.getDocumentFileFromUri(uri).findFile(part); + if (dFile != null) uri = dFile.getUri(); + } + return uri; + } catch (NullPointerException e) { + // + } + } + return null; + } + + /* + * Mainly uses DocumentsContract to move through the dir's and files for speed. + * On an issue - should the faster but complex code fail - falls back to the incredibly slower + * DocumentFile.findFile() to get it. + */ + public static Uri getFullCWDUri(String param, String clientPath) { + Uri uri = getTreeUri(); + if (uri != null) { + try { + final int paramDirCount = param.split(File.separator).length; + final boolean makeParamNull = param.isEmpty() || paramDirCount > 1; + final String mParam = makeParamNull ? null : param; + final boolean isDir = param.contains(File.separator) || param.isEmpty(); + final TYPE type = isDir ? TYPE.dir : TYPE.file; + final DocumentFile result = customFindFile(uri, mParam, type, clientPath); + if (result != null) { + Uri cwdUri = result.getUri(); + // Double check and do the slow way if needed + String s = getScopedClientPath(clientPath, null, null); + if (s.isEmpty() && param.contains(File.separator)) { + s = param.substring(0, param.lastIndexOf(File.separator)); + } + String[] split = s.split(File.separator); + String lastSubDir = split[split.length - 1]; + final String path = cwdUri.getPath(); + if (path != null && !path.contains(lastSubDir)) return getFullCWDUriSlow(param); + return result.getUri(); + } + } catch (NullPointerException e) { + // + } + } + return null; + } + + /* + * https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew + * Thanks to spring.ace for providing an example. + * HUGE performance improvement over DocumentFile.findFile(). + * Use at least until Google fixes DocumentFile.findFile() performance (should it ever happen). + */ + public static DocumentFile customFindFile(Uri dirUri, @Nullable String filename, TYPE type, String clientPath) { + ContentResolver contentResolver = App.getAppContext().getContentResolver(); + Uri uri = DocumentsContract.buildChildDocumentsUriUsingTree(dirUri,DocumentsContract + .getTreeDocumentId(dirUri)); + DocumentFile f = null; + List uriList = new LinkedList<>(); + uriList.add(uri); + final String chroot = FsSettings.getDefaultChrootDir().getPath(); + String userPath = getScopedClientPath(clientPath, null, null); + if (userPath.contains(chroot)) userPath = userPath.replace(chroot, ""); + if (userPath.contains(File.separator)) { + // Stop initial empty from being in the array as that messes things up + if (userPath.startsWith(File.separator)) + userPath = userPath.replaceFirst(File.separator, ""); + } + String[] userPathEachDir = userPath.split(File.separator); + if (userPathEachDir.length == 1 && userPathEachDir[0].isEmpty()) { + userPathEachDir = new String[0]; + } + final int userPathMax = userPathEachDir.length; + int userPathLoc = 0; + boolean dirMovementFinished = userPathMax == 0 && type == TYPE.file; + boolean end = false; + while (!end) { + uri = uriList.remove(0); + try (Cursor c = contentResolver.query(uri, new String[]{ + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE}, + null, null, null)) { + while (c!= null && c.moveToNext()) { + final String docId = c.getString(0); + final String name = c.getString(1); + final String mime = c.getString(2); + if (isDirectory(mime) && userPathLoc < userPathMax) { + // Move to the target folder from the chosen dir using user supplied path + if (isMatchFound(name, userPathEachDir[userPathLoc])) { + // Add so it can move into on next loop + userPathLoc++; + final Uri mUri = DocumentsContract.buildChildDocumentsUriUsingTree(dirUri, docId); + uriList.add(mUri); + if (userPathLoc == userPathMax) { + // If type is dir, then once path is found - including any sub + // path - it should stop here. Whereas file should continue. + dirMovementFinished = true; + if (type == TYPE.dir) { + // Now we can find the dir and return that + Uri mUri2 = DocumentsContract.buildDocumentUriUsingTree(uri, docId); + f = FileUtil.getDocumentFileFromUri(mUri2); + end = true; + break; + } + } + } + } else if (dirMovementFinished && isMatchFound(name, filename)) { + // Now we can find the file and return the Uri for that + Uri mUri = DocumentsContract.buildDocumentUriUsingTree(uri, docId); + f = FileUtil.getDocumentFileFromUri(mUri); + end = true; + break; + } else if (type == TYPE.dir && isMatchFound(name, filename != null ? filename : userPath)) { + // Now we can find the file and return the Uri for that + Uri mUri = DocumentsContract.buildDocumentUriUsingTree(uri, docId); + f = FileUtil.getDocumentFileFromUri(mUri); + end = true; + break; + } + } + } + if (uriList.isEmpty()) end = true; + } + if (type == TYPE.file && f != null && f.isDirectory()) return null; + // Fix for empty folder with no user provided client path: + if (type == TYPE.dir && f == null) { + f = FileUtil.getDocumentFileFromUri(dirUri); + } + return f; + } + + /* + * Checks if DocumentsContract entry is a dir or not + */ + private static boolean isDirectory(String mimeType) { + // As DocumentsContract.buildChildDocumentsUriUsingTree() states: + // "Must be a directory with MIME type of DocumentsContract.Document.MIME_TYPE_DIR." + return DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType); + } + + /* + * Compares two Strings to see if they match + */ + public static boolean isMatchFound(String s1, String s2) { + return s1.equals(s2); + } + + /* + * Cleans up a Uri tree path that contains non-path stuff such as "tree:", ":", etc. + */ + public static String cleanupUriStoragePath(Uri uri) { + if (uri == null) return ""; + String s = uri.getPath(); + if (s != null && s.contains("tree:")) s = s.replace("tree:", ""); + else if (s != null && s.contains("/tree/")) s = s.replace("/tree/", ""); + if (s != null && s.contains(File.pathSeparator)) { + String s2 = s.substring(0, s.indexOf(File.pathSeparator) - 1); + s = s.substring(s.lastIndexOf(s2)); + } + return s; + } + + /* + * Gets the picker path directly from the DocumentFile which doesn't include eg "/storage/" + */ + public static String getTreeUriStoragePath(DocumentFile file) { + if (file == null) return null; + String path = cleanupUriStoragePath(file.getUri()); // gets path from user root on out + if (path != null) { + // Make compatible with sub folders + // "primary:" seems to be internal verses just the ":" found with sd card. + // Well, sd card use could also be explained as "[sd card name]:" verses "[name for internal]:" + if (path.contains("primary:")) { + // Internal storage + path = path.replace("primary:", ""); + } else if (path.contains(":")) { + // We can know sd card path from the Uri so nothing else is needed :) + path = path.replace(":", File.separator); + } + // Need eg /storage/emulated/0/ for internal or /storage/ for sd card + return path; + } + return null; + } + + /* + * Gets the full path using the DocumentFile including eg "/storage/ and all the way to the picker folder + */ + public static String getUriStoragePathFullFromDocumentFile(DocumentFile file, String param) { + String partial = getTreeUriStoragePath(file); + if (partial != null) { + String chrootPath = FsSettings.getDefaultChrootDir().getPath(); + // Fix an issue with older Android versions using the new code getting the path wrong + // here because of using Manage users chroot field. Chroot will contain the whole path. + String preLastDir = partial; + String lastDir = partial; + if (partial.contains(File.separator)) { + preLastDir = partial.substring(0, partial.lastIndexOf(File.separator)); + lastDir = partial.substring(partial.lastIndexOf(File.separator)); + } + if (chrootPath.contains(preLastDir) && !chrootPath.endsWith(lastDir)) { + // Moving up or file + return chrootPath + lastDir; + } else if (chrootPath.endsWith(partial) && partial.split(File.separator).length > 1) { + // Don't duplicate + return chrootPath; + } + // Need eg /storage/emulated/0/TestMain/TestSub/ or /storage/3abc-sdcard/Test/ + final boolean isSDCard = getSdCardNameScopedStorage() != null; + if (isSDCard) return chrootPath + File.separator + partial; + else { + // Includes various checks & fixes for variances as seen on one device and internal + if (!chrootPath.endsWith(File.separator)) chrootPath += File.separator; + if (!param.contains(File.separator)) { + return chrootPath; // file (dirs only here.) or empty + } + final String clientPath = getScopedClientPath(param, null, null); + if (clientPath.startsWith(File.separator) && chrootPath.endsWith(File.separator)) { + chrootPath = chrootPath.substring(0, chrootPath.length() - 1); + } + return chrootPath + clientPath; + } + } + return null; + } + + /** + * Returns the client path or empty if at chroot from any of the supplied values. + * At the very least: + * @param s must be supplied which is the full path of the current dir. + * */ + public static String getScopedClientPath(String s, @Nullable File f, @Nullable String t) { + if (s == null || s.isEmpty()) return ""; // at root + String tree; + if (t != null) tree = t; + else tree = FileUtil.cleanupUriStoragePath(FileUtil.getTreeUri()); + if (tree.contains(File.pathSeparator)) tree = tree.substring(tree.indexOf(File.pathSeparator) + 1); + String param; + if (f != null) param = f.toString(); + else param = s; + if (param.contains(tree)) param = param.substring(param.indexOf(tree) + tree.length()); + return param; + } + + /** + * Used to deduplicate some File and DocumentFile methods as eg .isDirectory() applies to both + * Thus, it gets rid of some horrible duplication that would have happened otherwise. + */ + public static class Gen { + T ob; + + public Gen(T o1) { + ob = o1; + } + + public T getOb() { + return ob; + } + + public boolean exists() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).exists(); + else return ((File) getOb()).exists(); + } + + public String getName() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).getName(); + else return ((File) getOb()).getName(); + } + + public boolean isDirectory() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).isDirectory(); + else return ((File) getOb()).isDirectory(); + } + + public long length() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).length(); + else return ((File) getOb()).length(); + } + + public long lastModified() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).lastModified(); + else return ((File) getOb()).lastModified(); + } + + public boolean isFile() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).isFile(); + else return ((File) getOb()).isFile(); + } + + public boolean canRead() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).canRead(); + else return ((File) getOb()).canRead(); + } + + public boolean canWrite() { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).canWrite(); + else return ((File) getOb()).canWrite(); + } + + public String getCanonicalPath() throws IOException { + if (getOb() instanceof DocumentFile) return ((DocumentFile) getOb()).getUri().getPath(); + else return ((File) getOb()).getCanonicalPath(); + } + } + + public static Gen convertDocumentFileToGen(DocumentFile f) { + return new Gen<>(f); + } + + public static Gen convertFileToGen(File f) { + return new Gen<>(f); + } + + /* + * Use under certain conditions only as File use isn't fully compatible with scoped storage and + * will throw security exceptions under incorrect use. + * */ + public static Gen createGenFromFile(File f) { + if (Util.useScopedStorage()) { + return FileUtil.convertDocumentFileToGen(FileUtil.getDocumentFileFromFileScopedStorage(f, f.getPath())); + } else { + return FileUtil.convertFileToGen(f); + } + } } \ No newline at end of file From 8fcae434147562b86ff435f8bf374f22e0e36e97 Mon Sep 17 00:00:00 2001 From: xav Date: Sat, 25 Mar 2023 17:18:03 -0400 Subject: [PATCH 2/6] Various path related fixes --- app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java | 9 +++++++-- app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java | 7 ++++++- app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java | 8 +++++++- app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java | 2 ++ app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java | 8 ++++++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java b/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java index 076adfae..53b354f4 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java @@ -48,8 +48,13 @@ public void run() { sessionThread.getWorkingDir(), param, false); if (Util.useScopedStorage()) { - final String clientPath = storeFile.getPath().substring(0, storeFile.getPath() - .lastIndexOf(File.separator)); + String clientPath; + final String sfPath = storeFile.getPath(); + if (sfPath.contains(File.separator)) { + clientPath = sfPath.substring(0, sfPath.lastIndexOf(File.separator)); + } else { + clientPath = sfPath; + } DocumentFile docStoreFile = FileUtil.getDocumentFileWithParamScopedStorage(File.separator + param, null, clientPath); tryToDelete(new FileUtil.Gen(docStoreFile), clientPath); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java index 0b112225..96c33d4a 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java @@ -51,14 +51,19 @@ public void run() { // user-visible path (inside the chroot directory). try { String currentDir = sessionThread.getWorkingDir().getCanonicalPath(); + File chrootDir = sessionThread.getChrootDir(); if (Util.useScopedStorage()) { Uri uri = FileUtil.getFullCWDUri("", currentDir); DocumentFile df = null; if (uri != null) df = FileUtil.getDocumentFileFromUri(uri); final String path = FileUtil.getScopedClientPath(currentDir, null, null); if (df != null) currentDir = FileUtil.getUriStoragePathFullFromDocumentFile(df, path); + if (chrootDir != null) { + final String chroot = chrootDir.getPath(); + // Send back path as "/" instead of as chroot + if (currentDir != null && currentDir.equals(chroot)) currentDir = "/"; + } } else { - File chrootDir = sessionThread.getChrootDir(); if (chrootDir != null) { currentDir = currentDir.substring(chrootDir.getCanonicalPath().length()); } diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java index 41199de0..6b788b66 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java @@ -59,7 +59,13 @@ public void run() { DocumentFile docFileToRetr = null; if (Util.useScopedStorage()) { - final String clientPath = fileToRetr.getPath().substring(0, fileToRetr.getPath().lastIndexOf(File.separator)); + String clientPath; + final String ftrPath = fileToRetr.getPath(); + if (ftrPath.contains(File.separator)) { + clientPath = ftrPath.substring(0, ftrPath.lastIndexOf(File.separator)); + } else { + clientPath = ftrPath; + } docFileToRetr = FileUtil.getDocumentFileWithParamScopedStorage(param, null, clientPath); if (docFileToRetr == null) { errString = "550 File does not exist\r\n"; diff --git a/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java b/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java index 91f368fd..b850b732 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java +++ b/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java @@ -223,6 +223,8 @@ public static File inputPathToChrootedFile(final File chrootDir, final File exis // entire path needed. And then sometimes it doesn't have the entire path lol :) if (param.contains(tree)) { return new File(param); + } else if (param.equals(File.separator)) { + return new File(tree); // Fix an occasion where it does not return to root } } String mPath = ""; diff --git a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java index d2e9ed1d..5b2cbb18 100644 --- a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java +++ b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java @@ -735,9 +735,13 @@ public static DocumentFile getDocumentFile(final File file, final boolean isDire Uri treeUri = null; if (as != null) treeUri = Uri.parse(as); - if (treeUri == null) { - return null; + if (Util.useScopedStorage() && treeUri == null) { + // Fix Android 8.0 sd card having null with FsSettings.getExternalStorageUri() + // The rest works fine after this + treeUri = getTreeUri(); } + if (treeUri == null) return null; + if (file.exists()) { Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri) + relativePath); DocumentFile document = DocumentFile.fromSingleUri(context, documentUri); From 7d3424a426bec0703a764b1ad0063a5ea924e4b9 Mon Sep 17 00:00:00 2001 From: xav Date: Sun, 26 Mar 2023 14:42:03 -0400 Subject: [PATCH 3/6] Fixed the URI path not reaching the correct dir by continuing on whatever is left in the dir where its already been matched. If a dir exists at that point (that matched the next) then it would enter an incorrect dir and cause files to become incorrectly placed or to fail to transfer. --- app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java index 5b2cbb18..f18ce01b 100644 --- a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java +++ b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java @@ -1035,6 +1035,9 @@ public static DocumentFile customFindFile(Uri dirUri, @Nullable String filename, end = true; break; } + } else if (!c.isLast()) { + // fix: Don't continue in the dir as it can wrongly match + break; } } } else if (dirMovementFinished && isMatchFound(name, filename)) { From ffbab8ee4e0f482658cb656e833eb86eb2025488 Mon Sep 17 00:00:00 2001 From: xav Date: Mon, 27 Mar 2023 12:58:01 -0400 Subject: [PATCH 4/6] Fix write external storage override for older devices (to use the scoped storage code) not working on first attempt (or even until app was force ended at that). --- app/src/main/java/be/ppareit/swiftp/Util.java | 6 ++---- .../main/java/be/ppareit/swiftp/gui/PreferenceFragment.java | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/be/ppareit/swiftp/Util.java b/app/src/main/java/be/ppareit/swiftp/Util.java index 5492b811..e6f6af13 100644 --- a/app/src/main/java/be/ppareit/swiftp/Util.java +++ b/app/src/main/java/be/ppareit/swiftp/Util.java @@ -118,10 +118,8 @@ public static Date parseDate(String time) throws ParseException { * Uses an override for when File fails to work during a test as the user sets up the app. * */ public static boolean useScopedStorage() { - if (sp == null) { - sp = PreferenceManager.getDefaultSharedPreferences(App.getAppContext()); - overrideSDKVer = sp.getBoolean("OverrideScopedStorageMinimum", false); - } + if (sp == null) sp = PreferenceManager.getDefaultSharedPreferences(App.getAppContext()); + overrideSDKVer = sp.getBoolean("OverrideScopedStorageMinimum", false); return Build.VERSION.SDK_INT >= 30 || overrideSDKVer; } diff --git a/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java b/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java index b6e4f32e..fe923220 100644 --- a/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java +++ b/app/src/main/java/be/ppareit/swiftp/gui/PreferenceFragment.java @@ -318,7 +318,8 @@ private void tryToUpgradeToScopedStorage(Uri treeUri) { File root = new File(a11Path); if (!root.canRead() || !root.canWrite()) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(App.getAppContext()); - sp.edit().putBoolean("OverrideScopedStorageMinimum", true).apply(); + // Fix: Use commit; override in next method using useScopedStorage() needs it. + sp.edit().putBoolean("OverrideScopedStorageMinimum", true).commit(); } } } From f3bb8972625f9a1047669b5aab6ae4399353f49c Mon Sep 17 00:00:00 2001 From: xav Date: Mon, 27 Mar 2023 22:51:27 -0400 Subject: [PATCH 5/6] Fixed dirs having empty listing in specific situations. --- .../main/java/be/ppareit/swiftp/utils/FileUtil.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java index f18ce01b..bda7bf45 100644 --- a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java +++ b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java @@ -967,7 +967,15 @@ public static Uri getFullCWDUri(String param, String clientPath) { String[] split = s.split(File.separator); String lastSubDir = split[split.length - 1]; final String path = cwdUri.getPath(); - if (path != null && !path.contains(lastSubDir)) return getFullCWDUriSlow(param); + if (path != null && !path.contains(lastSubDir)) { + // Fix: eg DCIM folder as target where pushing a file to it then makes the + // listing empty from using this as fallback here. Shouldn't fallback anyway + // as the faster custom way does have it correct. + String chroot = getUriStoragePathFullFromDocumentFile(result, ""); + if (chroot == null || !chroot.contains(s)) { + return getFullCWDUriSlow(param); + } + } return result.getUri(); } } catch (NullPointerException e) { From 47efb9c3252f678ebf262abbd8d66a3ccd1d07b5 Mon Sep 17 00:00:00 2001 From: xav Date: Tue, 4 Apr 2023 11:24:10 -0400 Subject: [PATCH 6/6] Fixed path issues... simplified path code with return to some older code that's now working fine with it after things have been fixed in past commits and a couple more in this one. Didn't see that one coming. --- .../swiftp/server/CmdAbstractStore.java | 2 +- .../java/be/ppareit/swiftp/server/CmdCWD.java | 2 +- .../be/ppareit/swiftp/server/CmdDELE.java | 2 +- .../be/ppareit/swiftp/server/CmdHASH.java | 4 +- .../be/ppareit/swiftp/server/CmdMDTM.java | 6 +- .../be/ppareit/swiftp/server/CmdMFMT.java | 4 +- .../java/be/ppareit/swiftp/server/CmdMKD.java | 2 +- .../be/ppareit/swiftp/server/CmdMLST.java | 2 +- .../java/be/ppareit/swiftp/server/CmdPWD.java | 19 +--- .../be/ppareit/swiftp/server/CmdRETR.java | 2 +- .../java/be/ppareit/swiftp/server/CmdRMD.java | 2 +- .../be/ppareit/swiftp/server/CmdRNFR.java | 4 +- .../be/ppareit/swiftp/server/CmdRNTO.java | 3 +- .../java/be/ppareit/swiftp/server/FtpCmd.java | 97 +------------------ .../be/ppareit/swiftp/utils/FileUtil.java | 12 ++- 15 files changed, 26 insertions(+), 137 deletions(-) diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java b/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java index 4483a4d7..803b168d 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdAbstractStore.java @@ -49,7 +49,7 @@ public void doStorOrAppe(String param, boolean append) { Cat.d("STOR/APPE executing with append = " + append); File storeFile = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); DocumentFile docStoreFile = null; String errString = null; FileOutputStream out = null; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java index b4d24d45..76110f4d 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdCWD.java @@ -42,7 +42,7 @@ public void run() { String errString = null; mainblock: { newDir = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, true); + sessionThread.getWorkingDir(), param); // Ensure the new path does not violate the chroot restriction if (violatesChroot(newDir)) { diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java b/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java index 53b354f4..63c3e966 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdDELE.java @@ -45,7 +45,7 @@ public void run() { Log.d(TAG, "DELE executing"); String param = getParameter(input); File storeFile = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); if (Util.useScopedStorage()) { String clientPath; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java b/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java index dc387b01..7cb41ed0 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdHASH.java @@ -1,7 +1,5 @@ package be.ppareit.swiftp.server; -import android.util.Log; - import net.vrallev.android.cat.Cat; import java.io.File; @@ -35,7 +33,7 @@ public void run() { mainblock: { fileToHash = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); if (violatesChroot(fileToHash)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java index 2e058cfb..b6a92780 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMDTM.java @@ -20,10 +20,6 @@ package be.ppareit.swiftp.server; import java.io.File; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.TimeZone; import be.ppareit.swiftp.Util; import android.util.Log; @@ -46,7 +42,7 @@ public void run() { Log.d(TAG, "run: MDTM executing, input: " + mInput); String param = getParameter(mInput); File file = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); if (file.exists()) { long lastModified = file.lastModified(); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java index b5233155..fd3a3dbd 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMFMT.java @@ -28,8 +28,6 @@ import be.ppareit.swiftp.Util; -import android.util.Log; - import net.vrallev.android.cat.Cat; /** @@ -75,7 +73,7 @@ public void run() { } File file = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), pathName, false); + sessionThread.getWorkingDir(), pathName); if (!file.exists()) { sessionThread.writeString("550 file does not exist on server\r\n"); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java index 29c102ea..8087e5d3 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMKD.java @@ -51,7 +51,7 @@ public void run() { break mainblock; } toCreate = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, true); + sessionThread.getWorkingDir(), param); if (violatesChroot(toCreate)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java b/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java index 63a5998f..4e34c767 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdMLST.java @@ -48,7 +48,7 @@ public void run() { param = "/"; }else{ fileToFormat = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); } if (fileToFormat.exists()) { diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java index 96c33d4a..791c2ad0 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdPWD.java @@ -52,25 +52,12 @@ public void run() { try { String currentDir = sessionThread.getWorkingDir().getCanonicalPath(); File chrootDir = sessionThread.getChrootDir(); - if (Util.useScopedStorage()) { - Uri uri = FileUtil.getFullCWDUri("", currentDir); - DocumentFile df = null; - if (uri != null) df = FileUtil.getDocumentFileFromUri(uri); - final String path = FileUtil.getScopedClientPath(currentDir, null, null); - if (df != null) currentDir = FileUtil.getUriStoragePathFullFromDocumentFile(df, path); - if (chrootDir != null) { - final String chroot = chrootDir.getPath(); - // Send back path as "/" instead of as chroot - if (currentDir != null && currentDir.equals(chroot)) currentDir = "/"; - } - } else { - if (chrootDir != null) { - currentDir = currentDir.substring(chrootDir.getCanonicalPath().length()); - } + if (chrootDir != null) { + currentDir = currentDir.substring(chrootDir.getCanonicalPath().length()); } // The root directory requires special handling to restore its // leading slash - if (currentDir == null || currentDir.length() == 0) { + if (currentDir.length() == 0) { currentDir = "/"; } sessionThread.writeString("257 \"" + currentDir + "\"\r\n"); diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java index 6b788b66..05197903 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRETR.java @@ -54,7 +54,7 @@ public void run() { mainblock: { fileToRetr = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); Uri uri; DocumentFile docFileToRetr = null; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java index c32d0754..5be8837d 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRMD.java @@ -91,7 +91,7 @@ public void run() { break mainblock; } toRemove = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); if (violatesChroot(toRemove)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java index 8908b2e0..191ca293 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRNFR.java @@ -21,8 +21,6 @@ import java.io.File; -import android.util.Log; - import net.vrallev.android.cat.Cat; /** @@ -49,7 +47,7 @@ public void run() { mainblock: { file = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); if (violatesChroot(file)) { errString = "550 Invalid name or chroot violation\r\n"; break mainblock; diff --git a/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java b/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java index bc553e38..4ad9b768 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java +++ b/app/src/main/java/be/ppareit/swiftp/server/CmdRNTO.java @@ -23,7 +23,6 @@ import java.io.IOException; import android.os.Build; -import android.util.Log; import net.vrallev.android.cat.Cat; @@ -55,7 +54,7 @@ public void run() { mainblock: { toFile = inputPathToChrootedFile(sessionThread.getChrootDir(), - sessionThread.getWorkingDir(), param, false); + sessionThread.getWorkingDir(), param); Cat.i("RNTO to file: " + toFile.getPath()); if (violatesChroot(toFile)) { errString = "550 Invalid name or chroot violation\r\n"; diff --git a/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java b/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java index b850b732..ef3cb9f8 100644 --- a/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java +++ b/app/src/main/java/be/ppareit/swiftp/server/FtpCmd.java @@ -19,7 +19,6 @@ package be.ppareit.swiftp.server; -import android.net.Uri; import android.util.Log; import androidx.documentfile.provider.DocumentFile; @@ -27,7 +26,6 @@ import java.io.File; import java.lang.reflect.Constructor; -import be.ppareit.swiftp.Util; import be.ppareit.swiftp.utils.FileUtil; public abstract class FtpCmd implements Runnable { @@ -189,100 +187,7 @@ static public String getParameter(String input) { return getParameter(input, false); } - public static File inputPathToChrootedFile(final File chrootDir, final File existingPrefix, - String param, final boolean isDirOnly) { - if (Util.useScopedStorage()) { - // NEW WAY - // Chroot is *1 tree & including eg *2 "/storage" that the tree doesn't contain. - // *3 The rest is provided by the client. - // *4 The param when with file is with *3 so we need to remove path from param when it is. - // *5 All that's left is to tack on the client provided path to the end of the tree. - // There are multiple conflicting oddities that happen and are dealt with here. - if (isDirOnly && !param.startsWith(File.separator)) param = File.separator + param; - // May be empty at times and param will instead have it. - String sessionClientPath = FileUtil.getScopedClientPath(param, existingPrefix, null); - // Get the full chroot path including storage. - Uri uri = FileUtil.getTreeUri(); - DocumentFile df = FileUtil.getDocumentFileFromUri(uri); - final String tree = FileUtil.getUriStoragePathFullFromDocumentFile(df, ""); - if (tree == null) return new File(""); // That's bad. Make following checks fail. - // Deal with param and client specified paths. - String paramClientPath = ""; - if (!isDirOnly && param.contains(File.separator)) { - final int lastSlash = param.lastIndexOf(File.separator); - paramClientPath = param.substring(0, lastSlash); - // Can't have it in there with the new code. That's a conflict! - param = param.substring(lastSlash + 1); - if (!paramClientPath.startsWith(File.separator)) { - // Keep it the same to avoid problems. - paramClientPath = File.separator + paramClientPath; - } - } else if (isDirOnly && param.contains(File.separator)) { - // To make things worse, the param can also be a full path such as in FileZilla and - // using its tree to jump randomly anywhere. Here, the param already contains the - // entire path needed. And then sometimes it doesn't have the entire path lol :) - if (param.contains(tree)) { - return new File(param); - } else if (param.equals(File.separator)) { - return new File(tree); // Fix an occasion where it does not return to root - } - } - String mPath = ""; - // To make it worse, param can be dir and have no file and no slash lol. So... - // added isDirOnly in order to know what the calling method is working on as that can tell us. - if (isDirOnly) { - if (!sessionClientPath.isEmpty() && !sessionClientPath.equals(param)) { - // Varoious fixes and checks of random ways it can do as seen on one device and internal - if (param.startsWith(File.separator) && !tree.startsWith(File.separator)) { - param = param.replaceFirst(File.separator, ""); - } else if (!param.startsWith(File.separator) && tree.startsWith(File.separator)) { - param = File.separator + param; - } - if (param.endsWith(File.separator) && !tree.endsWith(File.separator)) { - param = param.substring(0, param.length() - 1); - } else if (!param.endsWith(File.separator) && tree.endsWith(File.separator)) { - param += File.separator; - } - if (!param.equals(tree)) { - mPath = sessionClientPath + File.separator + param; - } - } else { - mPath = param; - } - } else { - if (!sessionClientPath.isEmpty() && paramClientPath.isEmpty()) - mPath = sessionClientPath; - else if (sessionClientPath.isEmpty() && !paramClientPath.isEmpty()) - mPath = paramClientPath; - else { - if (sessionClientPath.equals(paramClientPath)) mPath = sessionClientPath; - else mPath = paramClientPath; - } - } - // Various checks and fixes as to ways it could go as seen on one device and internal - if (!mPath.startsWith(File.separator) && !tree.endsWith(File.separator)) { - mPath = File.separator + mPath; - } else if (mPath.startsWith(File.separator) && tree.endsWith(File.separator)) { - mPath = mPath.replaceFirst(File.separator, ""); - } - // Finally get the full current path - String path = ""; - if (!mPath.contains(tree)) path = tree + mPath; - // Let's end this. - if (!isDirOnly){ - return new File(path, param); - } - if (sessionClientPath.equals(param)) { - final String doubleVision = sessionClientPath + param; - if (!path.endsWith(doubleVision)) { - // Correct double vision; permutation fix - return new File(path, param); - } - } - // Another possible end - return new File(path); - } - // OLD WAY (with issues and not compatible with the new way.) + public static File inputPathToChrootedFile(final File chrootDir, final File existingPrefix, String param) { try { if (param.charAt(0) == '/') { // The STOR contained an absolute path diff --git a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java index bda7bf45..ec7d07d9 100644 --- a/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java +++ b/app/src/main/java/be/ppareit/swiftp/utils/FileUtil.java @@ -317,8 +317,14 @@ public static boolean mkdir(final File file, Context context) { return true; } - // Try with Storage Access Framework. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && FileUtil.isOnExtSdCard(file, context)) { + if (Util.useScopedStorage()) { + // The original code below is having some random failures on Android 13 internal tests. + // This works around that and has no more failures. + DocumentFile documentFile = getDocumentFileFromFileScopedStorage(file, file.getPath()); + if (documentFile == null) return false; + return documentFile.exists(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && (FileUtil.isOnExtSdCard(file, context))) { + // Try with Storage Access Framework. DocumentFile document = getDocumentFile(file, true, context); if (document == null) { return false; @@ -1183,6 +1189,8 @@ public static String getScopedClientPath(String s, @Nullable File f, @Nullable S if (f != null) param = f.toString(); else param = s; if (param.contains(tree)) param = param.substring(param.indexOf(tree) + tree.length()); + // Fix for keeping path separators correct + if (!param.isEmpty() && !param.endsWith(File.separator)) param += File.separator; return param; }