diff --git a/src/dialogs/loader.js b/src/dialogs/loader.js
index e7a34cea4..dfb98946f 100644
--- a/src/dialogs/loader.js
+++ b/src/dialogs/loader.js
@@ -6,14 +6,15 @@ import restoreTheme from "lib/restoreTheme";
let loaderIsImmortal = false;
let onCancelCallback = null;
+let cancelButtonTimeout = null;
let $currentDialog = null;
let $currentMask = null;
const titleLoaderId = "__title-loader";
/**
* @typedef {object} LoaderOptions
- * @property {number} timeout Timeout in milliseconds after which the loader will be shown
- * @property {function():void} oncancel Callback function to be called when the loader is shown
+ * @property {number} timeout Delay before the cancel button is shown, in milliseconds
+ * @property {function():void} oncancel Callback invoked only when the user cancels
*/
/**
@@ -64,19 +65,21 @@ function create(titleText, message = "", options = {}) {
);
- const { timeout, oncancel } = options;
- if (typeof oncancel === "function") {
- onCancelCallback = oncancel;
- }
+ clearTimeout(cancelButtonTimeout);
+ cancelButtonTimeout = null;
+ onCancelCallback =
+ typeof options.oncancel === "function" ? options.oncancel : null;
- if (typeof timeout === "number") {
- setTimeout(() => {
+ if (typeof options.timeout === "number") {
+ cancelButtonTimeout = setTimeout(() => {
+ cancelButtonTimeout = null;
+ if (!$dialog.isConnected) return;
$dialog.append(
-
+
,
);
- }, timeout);
+ }, options.timeout);
}
if (!$oldLoader) {
@@ -98,6 +101,13 @@ function create(titleText, message = "", options = {}) {
};
}
+function cancel() {
+ const callback = onCancelCallback;
+ onCancelCallback = null;
+ destroy();
+ callback?.();
+}
+
function createTitleLoader() {
const $titleLoader = tag.get(`#${titleLoaderId}`) || (
@@ -116,6 +126,9 @@ function createTitleLoader() {
function destroy() {
const loaderDiv = tag.get("#__loader");
const mask = tag.get("#__loader-mask");
+ clearTimeout(cancelButtonTimeout);
+ cancelButtonTimeout = null;
+ onCancelCallback = null;
restoreTheme();
if (!loaderDiv && !mask) {
@@ -128,7 +141,6 @@ function destroy() {
actionStack.unfreeze();
if (loaderDiv?.isConnected) loaderDiv.remove();
if (mask?.isConnected) mask.remove();
- onCancelCallback?.();
}, 300);
}
diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js
index 41bff24af..803be8af4 100644
--- a/src/fileSystem/sftp.js
+++ b/src/fileSystem/sftp.js
@@ -407,6 +407,23 @@ class SftpClient {
}
}
+ /**
+ * Tests a profile once without the normal remote-filesystem retry policy.
+ * @param {string} requestID Native request ID used for cancellation
+ */
+ testConnection(requestID) {
+ return new Promise((resolve, reject) => {
+ sftp.testProfile(this.#profileID, requestID, 10000, resolve, reject);
+ });
+ }
+
+ /** Cancel an in-flight profile test. */
+ cancelConnection(requestID) {
+ return new Promise((resolve) => {
+ sftp.cancelConnection(requestID, resolve, resolve);
+ });
+ }
+
async #connectWithRetry() {
const attempts = settings.value.retryRemoteFsAfterFail
? this.#MAX_TRY + 1
diff --git a/src/lib/remoteStorage.js b/src/lib/remoteStorage.js
index 83c8077b7..967f5fcb5 100644
--- a/src/lib/remoteStorage.js
+++ b/src/lib/remoteStorage.js
@@ -35,7 +35,7 @@ export default {
try {
loader.create(strings["add ftp"], strings["connecting..."], {
timeout: 10000,
- callback() {
+ oncancel() {
stopConnection = true;
},
});
@@ -176,6 +176,8 @@ export default {
existingProfile = null,
} = {}) {
let stopConnection = false;
+ let connection;
+ const connectionRequestID = `add-sftp-${helpers.uuid()}`;
if (existingProfile?.profileId) {
try {
const saved = await getSftpProfileInfo(existingProfile.profileId);
@@ -242,17 +244,21 @@ export default {
const url = createSftpProfileUrl(profile.profileId);
loader.create(strings["add sftp"], strings["connecting..."], {
- timeout: 10000,
- callback() {
+ timeout: 0,
+ oncancel() {
stopConnection = true;
+ connection?.cancelConnection(connectionRequestID);
},
});
- const connection = Sftp(null, 22, null, {
+ connection = Sftp(null, 22, null, {
profileID: profile.profileId,
});
try {
- const [home] = await Promise.all([connection.pwd(), loadAd()]);
+ const [home] = await Promise.all([
+ connection.testConnection(connectionRequestID),
+ loadAd(),
+ ]);
if (stopConnection) {
stopConnection = false;
diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js
index c7c6ffaf9..b26758b18 100644
--- a/src/pages/fileBrowser/fileBrowser.js
+++ b/src/pages/fileBrowser/fileBrowser.js
@@ -1468,8 +1468,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
const timeout = setTimeout(() => {
loader.create(name, strings.loading + "...", {
timeout: loaderTimeout,
- callback() {
- loader.destroy();
+ oncancel() {
navigate("/", "/");
progress[id] = false;
},
diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts
index dc75b3536..ca7db1d09 100644
--- a/src/plugins/sftp/index.d.ts
+++ b/src/plugins/sftp/index.d.ts
@@ -42,6 +42,10 @@ interface Sftp {
exec(command: String, onSucess: (res: ExecResult)=>void, onFail: (err: any) => void): void;
/** Connects using credentials held by the native profile store. */
connectUsingProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void;
+ /** Tests a profile once and returns its remote working directory. */
+ testProfile(profileId: String, requestId: String, timeout: Number, onSuccess: (home: String) => void, onFail: (err: any) => void): void;
+ /** Cancels an in-flight profile connection or test. */
+ cancelConnection(requestId: String, onSuccess: () => void, onFail: (err: any) => void): void;
saveProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profileId: String) => void, onFail: (err: any) => void): void;
editProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void;
getProfileInfo(profileId: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void;
diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java
index b676c1f05..a72466607 100644
--- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java
+++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java
@@ -53,6 +53,8 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.cordova.CallbackContext;
@@ -72,8 +74,13 @@ public class Sftp extends CordovaPlugin {
// A smaller mobile window prevents connection bursts from exhausting the heap.
private static final long SFTP_MAX_WINDOW_SIZE = 1024L * 1024L;
private static final long SFTP_MIN_WINDOW_SIZE = 128L * 1024L;
+ private static final long DEFAULT_CONNECT_TIMEOUT_MS = 10000L;
+ private static final long MIN_CONNECT_TIMEOUT_MS = 1000L;
+ private static final long MAX_CONNECT_TIMEOUT_MS = 30000L;
private static boolean cryptoProviderConfigured;
private final Object connectionLock = new Object();
+ private final Map connectionAttempts =
+ new ConcurrentHashMap<>();
private final Map remoteShells = new ConcurrentHashMap<>();
private SshClient ssh;
private SftpClient sftp;
@@ -82,6 +89,50 @@ public class Sftp extends CordovaPlugin {
private String connectionID;
private SftpSecurityStore securityStore;
+ private final class ConnectionAttempt {
+
+ private final CallbackContext callback;
+ private final AtomicBoolean cancelled = new AtomicBoolean(false);
+ private final AtomicBoolean completed = new AtomicBoolean(false);
+ private volatile Future> future;
+
+ private ConnectionAttempt(CallbackContext callback) {
+ this.callback = callback;
+ }
+
+ private void setFuture(Future> future) {
+ this.future = future;
+ if (cancelled.get()) future.cancel(true);
+ }
+
+ private boolean isCancelled() {
+ return cancelled.get();
+ }
+
+ private void cancel() {
+ cancelled.set(true);
+ Future> current = future;
+ if (current != null) current.cancel(true);
+ error(connectionCancelledError());
+ }
+
+ private void success() {
+ if (completed.compareAndSet(false, true)) callback.success();
+ }
+
+ private void success(String value) {
+ if (completed.compareAndSet(false, true)) callback.success(value);
+ }
+
+ private void error(String message) {
+ if (completed.compareAndSet(false, true)) callback.error(message);
+ }
+
+ private void error(JSONObject error) {
+ if (completed.compareAndSet(false, true)) callback.error(error);
+ }
+ }
+
private final class ConnectionSecurity {
private final String hostname;
@@ -162,12 +213,17 @@ public boolean verifyHost(String host, SshPublicKey publicKey)
}
private boolean report(CallbackContext callback) {
- JSONObject error = failure;
- failure = null;
+ JSONObject error = takeFailure();
if (error == null) return false;
callback.error(error);
return true;
}
+
+ private JSONObject takeFailure() {
+ JSONObject error = failure;
+ failure = null;
+ return error;
+ }
}
private final class RemoteShell {
@@ -318,11 +374,16 @@ private void closeConnectionQuietly() {
private boolean establishConnection(
SshClientBuilder builder,
String newConnectionID,
- ConnectionSecurity security
+ ConnectionSecurity security,
+ ConnectionAttempt attempt
) throws IOException, SshException, PermissionDeniedException {
synchronized (connectionLock) {
closeConnectionQuietly();
ssh = builder.onConfigure(security::configure).build();
+ if (attempt.isCancelled()) {
+ closeConnectionQuietly();
+ return false;
+ }
if (!ssh.isConnected()) {
closeConnectionQuietly();
return false;
@@ -336,6 +397,11 @@ private boolean establishConnection(
throw e;
}
+ if (attempt.isCancelled()) {
+ closeConnectionQuietly();
+ return false;
+ }
+
try {
sftp.getSubsystemChannel().setCharsetEncoding("UTF-8");
} catch (UnsupportedEncodingException | SshException e) {
@@ -371,6 +437,17 @@ private static JSONObject hostKeyFailure(
return error;
}
+ private static JSONObject connectionCancelledError() {
+ JSONObject error = new JSONObject();
+ try {
+ error.put("code", "SFTP_CONNECT_CANCELLED");
+ error.put("message", "SFTP connection cancelled");
+ error.put("cancelled", true);
+ error.put("nonRetryable", true);
+ } catch (JSONException ignored) {}
+ return error;
+ }
+
private boolean confirmUnknownHost(
String endpoint,
String algorithm,
@@ -440,23 +517,40 @@ private void closeRemoteShells() {
remoteShells.clear();
}
+ private void cancelConnectionAttempts() {
+ for (ConnectionAttempt attempt : connectionAttempts.values()) {
+ attempt.cancel();
+ }
+ connectionAttempts.clear();
+ }
+
@Override
public void onReset() {
+ cancelConnectionAttempts();
closeRemoteShells();
super.onReset();
}
@Override
public void onDestroy() {
+ cancelConnectionAttempts();
closeRemoteShells();
super.onDestroy();
}
private SshClientBuilder buildProfileBuilder(JSONObject profile)
throws IOException, InvalidPassphraseException, JSONException {
+ return buildProfileBuilder(profile, DEFAULT_CONNECT_TIMEOUT_MS);
+ }
+
+ private SshClientBuilder buildProfileBuilder(
+ JSONObject profile,
+ long connectTimeout
+ ) throws IOException, InvalidPassphraseException, JSONException {
SshClientBuilder builder = SshClientBuilder.create()
.withHostname(profile.getString("hostname"))
.withPort(profile.optInt("port", 22))
+ .withConnectTimeout(connectTimeout)
.withUsername(profile.getString("username"));
if ("key".equals(profile.optString("authType"))) {
@@ -868,6 +962,8 @@ private static boolean isAllowedAction(String action) {
switch (action) {
case "exec":
case "connectUsingProfile":
+ case "testProfile":
+ case "cancelConnection":
case "saveProfile":
case "editProfile":
case "getProfileInfo":
@@ -894,47 +990,132 @@ private static boolean isAllowedAction(String action) {
}
public void connectUsingProfile(JSONArray args, CallbackContext callback) {
- cordova
- .getThreadPool()
- .execute(
- new Runnable() {
- public void run() {
- ConnectionSecurity security = null;
- String profileID = args.optString(0);
- try {
- JSONObject profile = securityStore.getProfile(profileID);
- ConnectionSecurity profileSecurity = new ConnectionSecurity(
- profile.getString("hostname"),
- profile.optInt("port", 22)
+ startProfileConnection(args, callback, false);
+ }
+
+ public void testProfile(JSONArray args, CallbackContext callback) {
+ startProfileConnection(args, callback, true);
+ }
+
+ public void cancelConnection(JSONArray args, CallbackContext callback) {
+ ConnectionAttempt attempt = connectionAttempts.get(args.optString(0));
+ if (attempt != null) attempt.cancel();
+ callback.success();
+ }
+
+ private void startProfileConnection(
+ JSONArray args,
+ CallbackContext callback,
+ boolean returnWorkingDirectory
+ ) {
+ String suppliedRequestID = args.optString(1);
+ String requestID = suppliedRequestID.isEmpty()
+ ? UUID.randomUUID().toString()
+ : suppliedRequestID;
+ long requestedTimeout = args.optLong(2, DEFAULT_CONNECT_TIMEOUT_MS);
+ long connectTimeout = Math.max(
+ MIN_CONNECT_TIMEOUT_MS,
+ Math.min(requestedTimeout, MAX_CONNECT_TIMEOUT_MS)
+ );
+ ConnectionAttempt attempt = new ConnectionAttempt(callback);
+ if (connectionAttempts.putIfAbsent(requestID, attempt) != null) {
+ callback.error("An SFTP connection with this request ID is already running");
+ return;
+ }
+
+ FutureTask task = new FutureTask(
+ () -> {
+ ConnectionSecurity security = null;
+ String profileID = args.optString(0);
+ try {
+ JSONObject profile = securityStore.getProfile(profileID);
+ ConnectionSecurity profileSecurity = new ConnectionSecurity(
+ profile.getString("hostname"),
+ profile.optInt("port", 22)
+ );
+ security = profileSecurity;
+ SshClientBuilder builder = buildProfileBuilder(
+ profile,
+ connectTimeout
+ );
+ boolean connected;
+ String workingDirectory = null;
+ if (returnWorkingDirectory) {
+ synchronized (connectionLock) {
+ connected = establishConnection(
+ builder,
+ profileID,
+ profileSecurity,
+ attempt
);
- security = profileSecurity;
- if (
- establishConnection(
- buildProfileBuilder(profile),
- profileID,
- profileSecurity
- )
- ) {
- callback.success();
- return;
- }
- if (security.report(callback)) return;
- callback.error("Failed to establish SSH connection");
- } catch (InvalidPassphraseException e) {
- callback.error("Invalid passphrase for stored key");
- } catch (Exception e) {
- if (security != null && security.report(callback)) return;
- callback.error("Failed to connect SFTP profile: " + errMessage(e));
- Log.e(TAG, "Failed to connect SFTP profile", e);
- } catch (OutOfMemoryError e) {
- synchronized (connectionLock) {
- closeConnectionQuietly();
+ if (connected && !attempt.isCancelled()) {
+ SftpClient activeSftp = sftp;
+ if (activeSftp == null) {
+ throw new IOException(
+ "SFTP connection was closed before validation"
+ );
+ }
+ workingDirectory = activeSftp.pwd();
}
- callback.error("Not enough memory to initialize SFTP");
}
+ } else {
+ connected = establishConnection(
+ builder,
+ profileID,
+ profileSecurity,
+ attempt
+ );
+ }
+ if (connected) {
+ if (attempt.isCancelled()) return null;
+ if (returnWorkingDirectory) {
+ attempt.success(workingDirectory);
+ } else {
+ attempt.success();
+ }
+ return null;
+ }
+ if (attempt.isCancelled()) return null;
+ JSONObject securityError = security.takeFailure();
+ if (securityError != null) {
+ attempt.error(securityError);
+ return null;
+ }
+ attempt.error("Failed to establish SSH connection");
+ } catch (InvalidPassphraseException e) {
+ if (!attempt.isCancelled()) {
+ attempt.error("Invalid passphrase for stored key");
+ }
+ } catch (Exception e) {
+ if (attempt.isCancelled()) return null;
+ JSONObject securityError = security == null
+ ? null
+ : security.takeFailure();
+ if (securityError != null) {
+ attempt.error(securityError);
+ return null;
+ }
+ attempt.error("Failed to connect SFTP profile: " + errMessage(e));
+ Log.e(TAG, "Failed to connect SFTP profile", e);
+ } catch (OutOfMemoryError e) {
+ synchronized (connectionLock) {
+ closeConnectionQuietly();
+ }
+ if (!attempt.isCancelled()) {
+ attempt.error("Not enough memory to initialize SFTP");
}
}
- );
+ return null;
+ }
+ ) {
+ @Override
+ protected void done() {
+ connectionAttempts.remove(requestID, attempt);
+ }
+ };
+
+ attempt.setFuture(task);
+ cordova.getThreadPool().execute(task);
}
public void exec(JSONArray args, CallbackContext callback) {
diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js
index 1fbbb76e3..855cf1cdf 100644
--- a/src/plugins/sftp/www/sftp.js
+++ b/src/plugins/sftp/www/sftp.js
@@ -5,6 +5,12 @@ module.exports = {
connectUsingProfile: function (profileId, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingProfile', [profileId]);
},
+ testProfile: function (profileId, requestId, timeout, onSuccess, onFail) {
+ cordova.exec(onSuccess, onFail, 'Sftp', 'testProfile', [profileId, requestId, timeout]);
+ },
+ cancelConnection: function (requestId, onSuccess, onFail) {
+ cordova.exec(onSuccess, onFail, 'Sftp', 'cancelConnection', [requestId]);
+ },
saveProfile: function (profileId, host, port, username, authType, password, keyFile, passphrase, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'Sftp', 'saveProfile', [profileId, host, port, username, authType, password, keyFile, passphrase]);
},