mirokuratczyk 5 лет назад
Родитель
Сommit
07eda4983e

+ 109 - 59
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -63,10 +63,13 @@ import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ExecutorService;
 
 import psi.Psi;
 import psi.PsiphonProvider;
 import psi.PsiphonProviderNoticeHandler;
+import psi.PsiphonProviderFeedbackHandler;
 
 public class PsiphonTunnel {
 
@@ -74,6 +77,14 @@ public class PsiphonTunnel {
         default public void onDiagnosticMessage(String message) {}
     }
 
+    // Protocol used to communicate the outcome of feedback upload operations to the application
+    // using PsiphonTunnelFeedback.
+    public interface HostFeedbackHandler {
+        // Callback which is invoked once the feedback upload has completed.
+        // If the exception is non-null, then the upload failed.
+        default public void sendFeedbackCompleted(java.lang.Exception e) {}
+    }
+
     public interface HostService extends HostLogger {
 
         public String getAppName();
@@ -296,25 +307,6 @@ public class PsiphonTunnel {
         return Psi.importExchangePayload(payload);
     }
 
-    // Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics
-    // information in a particular format, then calls this function to upload it for later
-    // investigation. The feedback compatible config and upload path must be provided by
-    // Psiphon Inc.
-    public static void sendFeedback(Context context, HostLogger logger, String feedbackConfigJson,
-                                    String diagnosticsJson, String uploadPath,
-                                    String clientPlatformPrefix, String clientPlatformSuffix) throws Exception {
-
-        try {
-            // Adds fields used in feedback upload, e.g. client platform.
-            String psiphonConfig = buildPsiphonConfig(context, logger, feedbackConfigJson,
-                    clientPlatformPrefix, clientPlatformSuffix, false, 0);
-            PsiphonProviderNoticeHandlerShim noticeHandler = new PsiphonProviderNoticeHandlerShim(logger);
-            Psi.sendFeedback(psiphonConfig, diagnosticsJson, uploadPath, noticeHandler);
-        } catch (java.lang.Exception e) {
-            throw new Exception("Error sending feedback", e);
-        }
-    }
-
     // Writes Go runtime profile information to a set of files in the specifiec output directory.
     // cpuSampleDurationSeconds and blockSampleDurationSeconds determines how to long to wait and
     // sample profiles that require active sampling. When set to 0, these profiles are skipped.
@@ -322,6 +314,104 @@ public class PsiphonTunnel {
         Psi.writeRuntimeProfiles(outputDirectory, cpuSampleDurationSeconnds, blockSampleDurationSeconds);
     }
 
+    // The interface for managing the Psiphon feedback upload operations.
+    // Warning: should not be used in the same process as PsiphonTunnel.
+    public static class PsiphonTunnelFeedback {
+
+        final private ExecutorService workQueue;
+        final private ExecutorService callbackQueue;
+
+        public PsiphonTunnelFeedback() {
+            workQueue = Executors.newSingleThreadExecutor();
+            callbackQueue = Executors.newSingleThreadExecutor();
+        }
+
+        // Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics
+        // information in a particular format, then calls this function to upload it for later
+        // investigation. The feedback compatible config and upload path must be provided by
+        // Psiphon Inc. This call is asynchronous and returns before the upload completes. The
+        // operation has completed when sendFeedbackCompleted() is called on the provided
+        // HostFeedbackHandler. The provided HostLogger will be called to log informational notices,
+        // including warnings.
+        //
+        // Warning: only one active upload is supported at a time. An ongoing upload will be
+        // cancelled if this function is called again before it completes.
+        public void startSendFeedback(Context context, HostFeedbackHandler feedbackHandler, HostLogger logger,
+                                      String feedbackConfigJson, String diagnosticsJson, String uploadPath,
+                                      String clientPlatformPrefix, String clientPlatformSuffix) {
+
+            workQueue.submit(new Runnable() {
+                @Override
+                public void run() {
+                    try {
+                        // Adds fields used in feedback upload, e.g. client platform.
+                        String psiphonConfig = buildPsiphonConfig(context, logger, feedbackConfigJson,
+                                clientPlatformPrefix, clientPlatformSuffix, false, 0);
+
+                        Psi.startSendFeedback(psiphonConfig, diagnosticsJson, uploadPath,
+                                new PsiphonProviderFeedbackHandler() {
+                                    @Override
+                                    public void sendFeedbackCompleted(java.lang.Exception e) {
+                                        callbackQueue.submit(new Runnable() {
+                                            @Override
+                                            public void run() {
+                                                feedbackHandler.sendFeedbackCompleted(e);
+                                            }
+                                        });
+                                    }
+                                },
+                                new PsiphonProviderNoticeHandler() {
+                                    @Override
+                                    public void notice(String noticeJSON) {
+
+                                        try {
+                                            JSONObject notice = new JSONObject(noticeJSON);
+
+                                            String noticeType = notice.getString("noticeType");
+                                            if (noticeType == null) {
+                                                return;
+                                            }
+
+                                            JSONObject data = notice.getJSONObject("data");
+                                            if (data == null) {
+                                                return;
+                                            }
+
+                                            String diagnosticMessage = noticeType + ": " + data.toString();
+                                            callbackQueue.submit(new Runnable() {
+                                                @Override
+                                                public void run() {
+                                                    logger.onDiagnosticMessage(diagnosticMessage);
+                                                }
+                                            });
+                                        } catch (java.lang.Exception e) {
+                                            callbackQueue.submit(new Runnable() {
+                                                @Override
+                                                public void run() {
+                                                    logger.onDiagnosticMessage("Error handling notice " + e.toString());
+                                                }
+                                            });
+                                        }
+                                    }
+                                });
+                    } catch (java.lang.Exception e) {
+                        feedbackHandler.sendFeedbackCompleted(new Exception("Error sending feedback", e));
+                    }
+                }
+            });
+        }
+
+        // Interrupt an in-progress feedback upload operation started with startSendFeedback.
+        public void stopSendFeedback() {
+            workQueue.submit(new Runnable() {
+                @Override
+                public void run() {
+                    Psi.stopSendFeedback();
+                }
+            });
+        }
+    }
+
     //----------------------------------------------------------------------------------------------
     // VPN Routing
     //----------------------------------------------------------------------------------------------
@@ -495,46 +585,6 @@ public class PsiphonTunnel {
         }
     }
 
-    //----------------------------------------------------------------------------------------------
-    // PsiphonProviderNoticeHandler (Core support) interface implementation
-    //----------------------------------------------------------------------------------------------
-
-    // The PsiphonProviderNoticeHandler function is called from Go, and must be public to be
-    // accessible via the gobind mechanim. To avoid making internal implementation functions public,
-    // PsiphonProviderNoticeHandlerShim is used as a wrapper.
-
-    private static class PsiphonProviderNoticeHandlerShim implements PsiphonProviderNoticeHandler {
-
-        private HostLogger mLogger;
-
-        public PsiphonProviderNoticeHandlerShim(HostLogger logger) {
-            mLogger = logger;
-        }
-
-        @Override
-        public void notice(String noticeJSON) {
-
-            try {
-                JSONObject notice = new JSONObject(noticeJSON);
-
-                String noticeType = notice.getString("noticeType");
-                if (noticeType == null) {
-                    return;
-                }
-
-                JSONObject data = notice.getJSONObject("data");
-                if (data == null) {
-                    return;
-                }
-
-                String diagnosticMessage = noticeType + ": " + data.toString();
-                mLogger.onDiagnosticMessage(diagnosticMessage);
-            } catch (java.lang.Exception e) {
-                mLogger.onDiagnosticMessage("Error handling notice " + e.toString());
-            }
-        }
-    }
-
     private void notice(String noticeJSON) {
         handlePsiphonNotice(noticeJSON);
     }

+ 19 - 11
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/project.pbxproj

@@ -42,8 +42,10 @@
 		66BDB0681DC26CCC0079384C /* SBJson4Writer.m in Sources */ = {isa = PBXBuildFile; fileRef = 66BDB0591DC26CCC0079384C /* SBJson4Writer.m */; };
 		CE3D1DA523906003009A4AF6 /* Backups.h in Headers */ = {isa = PBXBuildFile; fileRef = CE3D1DA323906003009A4AF6 /* Backups.h */; };
 		CE3D1DA623906003009A4AF6 /* Backups.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3D1DA423906003009A4AF6 /* Backups.m */; };
-		CEDE547924EBF5980053566E /* PsiphonProviderNoticeHandlerShim.h in Headers */ = {isa = PBXBuildFile; fileRef = CEDE547724EBF5980053566E /* PsiphonProviderNoticeHandlerShim.h */; };
-		CEDE547A24EBF5980053566E /* PsiphonProviderNoticeHandlerShim.m in Sources */ = {isa = PBXBuildFile; fileRef = CEDE547824EBF5980053566E /* PsiphonProviderNoticeHandlerShim.m */; };
+		CEC229FC24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h in Headers */ = {isa = PBXBuildFile; fileRef = CEC229FA24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h */; };
+		CEC229FD24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.m in Sources */ = {isa = PBXBuildFile; fileRef = CEC229FB24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.m */; };
+		CEDE547924EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.h in Headers */ = {isa = PBXBuildFile; fileRef = CEDE547724EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.h */; };
+		CEDE547A24EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.m in Sources */ = {isa = PBXBuildFile; fileRef = CEDE547824EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.m */; };
 		EFED7EBF1F587F6E0078980F /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = EFED7EBE1F587F6E0078980F /* libresolv.tbd */; };
 /* End PBXBuildFile section */
 
@@ -108,8 +110,10 @@
 		66BDB0591DC26CCC0079384C /* SBJson4Writer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SBJson4Writer.m; sourceTree = "<group>"; };
 		CE3D1DA323906003009A4AF6 /* Backups.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Backups.h; sourceTree = "<group>"; };
 		CE3D1DA423906003009A4AF6 /* Backups.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Backups.m; sourceTree = "<group>"; };
-		CEDE547724EBF5980053566E /* PsiphonProviderNoticeHandlerShim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = PsiphonProviderNoticeHandlerShim.h; path = ../PsiphonProviderNoticeHandlerShim.h; sourceTree = "<group>"; };
-		CEDE547824EBF5980053566E /* PsiphonProviderNoticeHandlerShim.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PsiphonProviderNoticeHandlerShim.m; path = ../PsiphonProviderNoticeHandlerShim.m; sourceTree = "<group>"; };
+		CEC229FA24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PsiphonProviderNoticeHandlerShim.h; sourceTree = "<group>"; };
+		CEC229FB24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PsiphonProviderNoticeHandlerShim.m; sourceTree = "<group>"; };
+		CEDE547724EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = PsiphonProviderFeedbackHandlerShim.h; path = ../PsiphonProviderFeedbackHandlerShim.h; sourceTree = "<group>"; };
+		CEDE547824EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PsiphonProviderFeedbackHandlerShim.m; path = ../PsiphonProviderFeedbackHandlerShim.m; sourceTree = "<group>"; };
 		EFED7EBE1F587F6E0078980F /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
 /* End PBXFileReference section */
 
@@ -191,11 +195,11 @@
 		66BDB0221DA6BFCC0079384C /* PsiphonTunnel */ = {
 			isa = PBXGroup;
 			children = (
-				CE3D1D9E239056E4009A4AF6 /* Files */,
 				66BAD3321E525FBC00CD06DE /* JailbreakCheck */,
 				66BDB04A1DC26CCC0079384C /* json-framework */,
 				CEDE547B24EBF5A00053566E /* Psiphon */,
 				662659241DD270E900872F6C /* Reachability */,
+				CEC22A0624F0689000534D04 /* Utils */,
 				66BDB0231DA6BFCC0079384C /* PsiphonTunnel.h */,
 				66BDB0431DA6C7DD0079384C /* PsiphonTunnel.m */,
 				66BDB0241DA6BFCC0079384C /* Info.plist */,
@@ -243,20 +247,22 @@
 			path = "json-framework";
 			sourceTree = "<group>";
 		};
-		CE3D1D9E239056E4009A4AF6 /* Files */ = {
+		CEC22A0624F0689000534D04 /* Utils */ = {
 			isa = PBXGroup;
 			children = (
 				CE3D1DA323906003009A4AF6 /* Backups.h */,
 				CE3D1DA423906003009A4AF6 /* Backups.m */,
 			);
-			path = Files;
+			path = Utils;
 			sourceTree = "<group>";
 		};
 		CEDE547B24EBF5A00053566E /* Psiphon */ = {
 			isa = PBXGroup;
 			children = (
-				CEDE547724EBF5980053566E /* PsiphonProviderNoticeHandlerShim.h */,
-				CEDE547824EBF5980053566E /* PsiphonProviderNoticeHandlerShim.m */,
+				CEDE547724EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.h */,
+				CEDE547824EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.m */,
+				CEC229FA24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h */,
+				CEC229FB24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.m */,
 			);
 			path = Psiphon;
 			sourceTree = "<group>";
@@ -289,10 +295,11 @@
 				6685BDCA1E2E882800F0E414 /* Psi.h in Headers */,
 				66BDB0651DC26CCC0079384C /* SBJson4StreamWriterState.h in Headers */,
 				66BDB05B1DC26CCC0079384C /* SBJson4Parser.h in Headers */,
-				CEDE547924EBF5980053566E /* PsiphonProviderNoticeHandlerShim.h in Headers */,
+				CEDE547924EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.h in Headers */,
 				6685BDCD1E2E88A200F0E414 /* Psi-meta.h in Headers */,
 				66BDB05A1DC26CCC0079384C /* SBJson4.h in Headers */,
 				66BDB0611DC26CCC0079384C /* SBJson4StreamTokeniser.h in Headers */,
+				CEC229FC24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h in Headers */,
 				66BDB0631DC26CCC0079384C /* SBJson4StreamWriter.h in Headers */,
 				66BDB0671DC26CCC0079384C /* SBJson4Writer.h in Headers */,
 			);
@@ -421,7 +428,7 @@
 			files = (
 				66BDB05E1DC26CCC0079384C /* SBJson4StreamParser.m in Sources */,
 				66BDB0641DC26CCC0079384C /* SBJson4StreamWriter.m in Sources */,
-				CEDE547A24EBF5980053566E /* PsiphonProviderNoticeHandlerShim.m in Sources */,
+				CEDE547A24EBF5980053566E /* PsiphonProviderFeedbackHandlerShim.m in Sources */,
 				66BDB0661DC26CCC0079384C /* SBJson4StreamWriterState.m in Sources */,
 				66BDB05C1DC26CCC0079384C /* SBJson4Parser.m in Sources */,
 				4E89F7FE1E2ED3CE00005F4C /* LookupIPv6.c in Sources */,
@@ -429,6 +436,7 @@
 				66BDB0681DC26CCC0079384C /* SBJson4Writer.m in Sources */,
 				66BDB0621DC26CCC0079384C /* SBJson4StreamTokeniser.m in Sources */,
 				66BDB0441DA6C7DD0079384C /* PsiphonTunnel.m in Sources */,
+				CEC229FD24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.m in Sources */,
 				662659281DD270E900872F6C /* Reachability.m in Sources */,
 				66BDB0601DC26CCC0079384C /* SBJson4StreamParserState.m in Sources */,
 				CE3D1DA623906003009A4AF6 /* Backups.m in Sources */,

+ 0 - 34
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Files/Backups.h

@@ -1,34 +0,0 @@
-/*
-* Copyright (c) 2019, Psiphon Inc.
-* All rights reserved.
-*
-* This program is free software: you can redistribute it and/or modify
-* it under the terms of the GNU General Public License as published by
-* the Free Software Foundation, either version 3 of the License, or
-* (at your option) any later version.
-*
-* This program is distributed in the hope that it will be useful,
-* but WITHOUT ANY WARRANTY; without even the implied warranty of
-* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-* GNU General Public License for more details.
-*
-* You should have received a copy of the GNU General Public License
-* along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*
-*/
-
-#import <Foundation/Foundation.h>
-
-NS_ASSUME_NONNULL_BEGIN
-
-@interface Backups : NSObject
-
-/// Excludes the target file from application backups made by iCloud and iTunes.
-/// If NO is returned, the file was not successfully excluded from backup and the error is populated.
-/// @param filePath Path at which the file exists.
-/// @param err If non-nil, contains the error encountered when attempting to exclude the file from backup.
-+ (BOOL)excludeFileFromBackup:(NSString*)filePath err:(NSError**)err;
-
-@end
-
-NS_ASSUME_NONNULL_END

+ 0 - 38
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Files/Backups.m

@@ -1,38 +0,0 @@
-/*
-* Copyright (c) 2019, Psiphon Inc.
-* All rights reserved.
-*
-* This program is free software: you can redistribute it and/or modify
-* it under the terms of the GNU General Public License as published by
-* the Free Software Foundation, either version 3 of the License, or
-* (at your option) any later version.
-*
-* This program is distributed in the hope that it will be useful,
-* but WITHOUT ANY WARRANTY; without even the implied warranty of
-* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-* GNU General Public License for more details.
-*
-* You should have received a copy of the GNU General Public License
-* along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*
-*/
-
-#import "Backups.h"
-
-@implementation Backups
-
-// See comment in header
-+ (BOOL)excludeFileFromBackup:(NSString*)filePath err:(NSError**)err {
-    *err = nil;
-
-    // The URL must be of the file scheme ("file://"), otherwise the `setResourceValue:forKey:error`
-    // operation will silently fail with: "CFURLCopyResourcePropertyForKey failed because passed URL
-    // no scheme".
-    NSURL *urlWithScheme = [NSURL fileURLWithPath:filePath];
-
-    return [urlWithScheme setResourceValue:[NSNumber numberWithBool:YES]
-                                    forKey:NSURLIsExcludedFromBackupKey
-                                     error:err];
-}
-
-@end

+ 1 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonProviderNoticeHandlerShim.h → MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Psiphon/PsiphonProviderNoticeHandlerShim.h

@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
 /// @note This indirection is required because gomobile does not support Objective-C blocks.
 @interface PsiphonProviderNoticeHandlerShim : NSObject <GoPsiPsiphonProviderNoticeHandler>
 
-/// Initialize the notice handler with a given logger.
+/// Initialize the notice handler with the given logger.
 /// @param logger Logger which will receive notices.
 - (id)initWithLogger:(void (^__nonnull)(NSString *_Nonnull))logger;
 

+ 0 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonProviderNoticeHandlerShim.m → MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Psiphon/PsiphonProviderNoticeHandlerShim.m


+ 36 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonProviderFeedbackHandlerShim.h

@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2020, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#import <Foundation/Foundation.h>
+#import "PsiphonTunnel.h"
+#import "Psi-meta.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// PsiphonProviderFeedbackHandlerShim provides a shim between the internal GoPsiPsiphonProviderFeedbackHandler and exported
+/// PsiphonTunnelFeedbackDelegate interfaces.
+@interface PsiphonProviderFeedbackHandlerShim : NSObject <GoPsiPsiphonProviderFeedbackHandler>
+
+/// Initialize the shim with the given feedback delegate.
+/// @param handler Callback which is invoked when the feedback upload completes.
+- (id)initWithHandler:(void (^__nonnull)(NSError *_Nonnull))handler;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 42 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonProviderFeedbackHandlerShim.m

@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2020, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#import "PsiphonProviderFeedbackHandlerShim.h"
+
+@implementation PsiphonProviderFeedbackHandlerShim {
+    void (^handler) (NSError *_Nonnull);
+}
+
+- (id)initWithHandler:(void (^__nonnull)(NSError *_Nonnull))handler; {
+    self = [super init];
+    if (self != nil) {
+        self->handler = handler;
+    }
+    return self;
+}
+
+#pragma mark - GoPsiPsiphonProviderFeedbackHandler implementation
+
+- (void)sendFeedbackCompleted:(NSError *)err {
+    if (self->handler) {
+        self->handler(err);
+    }
+}
+
+@end

+ 51 - 19
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -54,7 +54,11 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
     PsiphonConnectionStateWaitingForNetwork
 };
 
-@protocol TunneledAppDelegateLogger <NSObject>
+/*!
+ @protocol PsiphonTunnelLoggerDelegate
+ Used to communicate diagnostic logs to the application that is using the PsiphonTunnel framework.
+ */
+@protocol PsiphonTunnelLoggerDelegate <NSObject>
 
 @optional
 
@@ -68,7 +72,6 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
 
 @end
 
-
 /*!
  @protocol TunneledAppDelegate
  Used to communicate with the application that is using the PsiphonTunnel framework,
@@ -76,7 +79,7 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
 
  All delegate methods will be called on a single serial dispatch queue. They will be made asynchronously unless otherwise noted (specifically when calling getPsiphonConfig and getEmbeddedServerEntries).
  */
-@protocol TunneledAppDelegate <NSObject, TunneledAppDelegateLogger>
+@protocol TunneledAppDelegate <NSObject, PsiphonTunnelLoggerDelegate>
 
 //
 // Required delegate methods
@@ -456,22 +459,6 @@ Returns the path where the rotated notices file will be created.
  */
 - (NSString * _Nonnull)getPacketTunnelDNSResolverIPv6Address;
 
-/*!
- Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics information in a particular format, then calls this function to upload it for later investigation.
- @param feedbackJson  The feedback data to upload.
- @param feedbackConfigJson  The feedback compatible config. Must be an NSDictionary or NSString. Must be provided by Psiphon Inc.
- @param uploadPath  The path at which to upload the diagnostic data. Must be provided by Psiphon Inc.
- @param logger  The logger which will be used to log informational notices including warnings.
- @param outError  Any error encountered while sending feedback. If set, then sending feedback failed.
- Swift: @code func sendFeedback(_ feedbackJson: String, feedbackConfigJson: Any, uploadPath: String, logger: TunneledAppDelegateLogger?, error outError: NSErrorPointer) @endcode
- */
-// See comment in header.
-+ (void)sendFeedback:(NSString * _Nonnull)feedbackJson
-  feedbackConfigJson:(id _Nonnull)feedbackConfigJson
-          uploadPath:(NSString * _Nonnull)uploadPath
-              logger:(id<TunneledAppDelegateLogger> _Nullable)logger
-               error:(NSError * _Nullable * _Nonnull)outError;
-
 /*!
  Provides the tunnel-core build info json as a string. See the tunnel-core build info code for details https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/common/buildinfo.go.
  @return  The build info json as a string.
@@ -490,3 +477,48 @@ Returns the path where the rotated notices file will be created.
 - (void)writeRuntimeProfilesTo:(NSString * _Nonnull)outputDirectory withCPUSampleDurationSeconds:(int)cpuSampleDurationSeconds withBlockSampleDurationSeconds:(int)blockSampleDurationSeconds;
 
  @end
+
+/*!
+ @protocol PsiphonTunnelFeedbackDelegate
+ Used to communicate the outcome of feedback upload operations to the application using the PsiphonTunnel framework.
+ */
+@protocol PsiphonTunnelFeedbackDelegate <NSObject>
+
+/// Called once the feedback upload has completed.
+/// @param err If non-nil, then the upload failed.
+- (void)sendFeedbackCompleted:(NSError * _Nullable)err;
+
+@end
+
+/*!
+ The interface for managing the Psiphon tunnel feedback upload operations.
+ @warning Should not be used in the same process as PsiphonTunnel.
+ */
+@interface PsiphonTunnelFeedback : NSObject
+
+/*!
+ Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics information in a particular format and then calls this
+ function to upload it for later investigation. This call is asynchronous and returns before the upload completes. The operation has
+ completed when `sendFeedbackCompleted:` is called on the provided `PsiphonTunnelFeedbackDelegate`.
+ @param feedbackJson The feedback data to upload.
+ @param feedbackConfigJson The feedback compatible config. Must be an NSDictionary or NSString. Config must be provided by
+ Psiphon Inc.
+ @param uploadPath The path at which to upload the diagnostic data. Must be provided by Psiphon Inc.
+ @param loggerDelegate Optional delegate which will be called to log informational notices, including warnings. Stored as a weak
+ reference; the caller is responsible for holding a strong reference.
+ @param feedbackDelegate Delegate which `sendFeedbackCompleted(error)` is called on once when the operation completes; if
+ error is non-nil, then the operation failed. Stored as a weak reference; the caller is responsible for holding a strong reference.
+ @warning Only one active upload is supported at a time. An ongoing upload will be cancelled if this function is called again before it
+ completes.
+ Swift: @code func sendFeedback(_ feedbackJson: String, feedbackConfigJson: Any, uploadPath: String, loggerDelegate: PsiphonTunnelLoggerDelegate?, feedbackDelegate: PsiphonTunnelSendFeedbackDelegate) @endcode
+ */
+- (void)startSendFeedback:(NSString * _Nonnull)feedbackJson
+       feedbackConfigJson:(id _Nonnull)feedbackConfigJson
+               uploadPath:(NSString * _Nonnull)uploadPath
+           loggerDelegate:(id<PsiphonTunnelLoggerDelegate> _Nullable)loggerDelegate
+         feedbackDelegate:(id<PsiphonTunnelFeedbackDelegate> _Nonnull)feedbackDelegate;
+
+/// Interrupt an in-progress feedback upload operation started with `startSendFeedback:`.
+- (void)stopSendFeedback;
+
+@end

+ 180 - 105
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -25,6 +25,7 @@
 #import <SystemConfiguration/CaptiveNetwork.h>
 #import "LookupIPv6.h"
 #import "Psi-meta.h"
+#import "PsiphonProviderFeedbackHandlerShim.h"
 #import "PsiphonProviderNoticeHandlerShim.h"
 #import "PsiphonTunnel.h"
 #import "Backups.h"
@@ -457,111 +458,6 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return GoPsiGetPacketTunnelDNSResolverIPv6Address();
 }
 
-// See comment in header.
-+ (void)sendFeedback:(NSString * _Nonnull)feedbackJson
-  feedbackConfigJson:(id _Nonnull)feedbackConfigJson
-          uploadPath:(NSString * _Nonnull)uploadPath
-              logger:(id<TunneledAppDelegateLogger> _Nullable)logger
-               error:(NSError * _Nullable * _Nonnull)outError {
-
-    *outError = nil;
-
-    void (^logMessage)(NSString * _Nonnull) = ^void(NSString * _Nonnull message) {
-        if (logger != nil && [logger respondsToSelector:@selector(onDiagnosticMessage:withTimestamp:)]) {
-            NSString *timestamp = [[PsiphonTunnel rfc3339Formatter] stringFromDate:[NSDate date]];
-            [logger onDiagnosticMessage:message withTimestamp:timestamp];
-        }
-    };
-
-    NSError *err;
-    NSString *sessionID = [PsiphonTunnel generateSessionID:&err];
-    if (err != nil) {
-        *outError = err;
-        return;
-    }
-
-    BOOL usingNoticeFiles = FALSE;
-    BOOL tunnelWholeDevice = FALSE;
-    NSString *psiphonConfig = [PsiphonTunnel buildPsiphonConfig:feedbackConfigJson
-                                               usingNoticeFiles:&usingNoticeFiles
-                                              tunnelWholeDevice:&tunnelWholeDevice
-                                                      sessionID:sessionID
-                                                     logMessage:logMessage
-                                                          error:&err];
-    if (err != nil) {
-        *outError = [NSError errorWithDomain:PsiphonTunnelErrorDomain
-                                        code:PsiphonTunnelErrorCodeConfigError
-                                    userInfo:@{NSLocalizedDescriptionKey:@"Error building config",
-                                               NSUnderlyingErrorKey:err}];
-        return;
-    } else if (psiphonConfig == nil) {
-        // Should never happen.
-        *outError = [NSError errorWithDomain:PsiphonTunnelErrorDomain
-                                        code:PsiphonTunnelErrorCodeConfigError
-                                    userInfo:@{NSLocalizedDescriptionKey:@"Error built config nil"}];
-        return;
-    }
-
-    // Convert notice to a diagnostic message and then log it.
-    void (^logNotice)(NSString * _Nonnull) = ^void(NSString * _Nonnull noticeJSON) {
-
-        if (logger != nil && [logger respondsToSelector:@selector(onDiagnosticMessage:withTimestamp:)]) {
-
-            __block NSDictionary *notice = nil;
-            id block = ^(id obj, BOOL *ignored) {
-                if (ignored == nil || *ignored == YES) {
-                    return;
-                }
-                notice = (NSDictionary *)obj;
-            };
-
-            id eh = ^(NSError *err) {
-                notice = nil;
-                logMessage([NSString stringWithFormat: @"Notice JSON parse failed: %@", err.description]);
-            };
-
-            id parser = [SBJson4Parser parserWithBlock:block allowMultiRoot:NO unwrapRootArray:NO errorHandler:eh];
-            [parser parse:[noticeJSON dataUsingEncoding:NSUTF8StringEncoding]];
-
-            if (notice == nil) {
-                return;
-            }
-
-            NSString *noticeType = notice[@"noticeType"];
-            if (noticeType == nil) {
-                logMessage(@"Notice missing noticeType");
-                return;
-            }
-
-            NSDictionary *data = notice[@"data"];
-            if (data == nil) {
-                return;
-            }
-
-            NSString *dataStr = [[[SBJson4Writer alloc] init] stringWithObject:data];
-            NSString *timestampStr = notice[@"timestamp"];
-            if (timestampStr == nil) {
-                return;
-            }
-
-            NSString *diagnosticMessage = [NSString stringWithFormat:@"%@: %@", noticeType, dataStr];
-            [logger onDiagnosticMessage:diagnosticMessage withTimestamp:timestampStr];
-        }
-    };
-
-    PsiphonProviderNoticeHandlerShim *noticeHandler = [[PsiphonProviderNoticeHandlerShim alloc] initWithLogger:logNotice];
-
-    GoPsiSendFeedback(psiphonConfig, feedbackJson, uploadPath, noticeHandler, &err);
-
-    if (err != nil) {
-        *outError = [NSError errorWithDomain:PsiphonTunnelErrorDomain
-                                        code:PsiphonTunnelErrorCodeSendFeedbackError
-                                    userInfo:@{NSLocalizedDescriptionKey:@"Error sending feedback",
-                                               NSUnderlyingErrorKey:err}];
-        return;
-    }
-}
-
 // See comment in header.
 + (NSString * _Nonnull)getBuildInfo {
     return GoPsiGetBuildInfo();
@@ -1743,3 +1639,182 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 }
 
 @end
+
+// See comment in header.
+@implementation PsiphonTunnelFeedback {
+    dispatch_queue_t workQueue;
+    dispatch_queue_t callbackQueue;
+}
+
+- (id)init {
+    self = [super init];
+    if (self) {
+        self->workQueue = dispatch_queue_create("com.psiphon3.library.feedback.WorkQueue", DISPATCH_QUEUE_SERIAL);
+        self->callbackQueue = dispatch_queue_create("com.psiphon3.library.feedback.CallbackQueue", DISPATCH_QUEUE_SERIAL);
+    }
+    return self;
+}
+
+// See comment in header.
+- (void)startSendFeedback:(NSString * _Nonnull)feedbackJson
+       feedbackConfigJson:(id _Nonnull)feedbackConfigJson
+               uploadPath:(NSString * _Nonnull)uploadPath
+           loggerDelegate:(id<PsiphonTunnelLoggerDelegate> _Nullable)loggerDelegate
+         feedbackDelegate:(id<PsiphonTunnelFeedbackDelegate> _Nonnull)feedbackDelegate {
+
+    dispatch_async(self->workQueue, ^{
+
+        __weak PsiphonTunnelFeedback *weakSelf = self;
+        __weak id<PsiphonTunnelLoggerDelegate> weakLogger = loggerDelegate;
+        __weak id<PsiphonTunnelFeedbackDelegate> weakFeedbackDelegate = feedbackDelegate;
+
+        void (^logMessage)(NSString * _Nonnull) = ^void(NSString * _Nonnull message) {
+            __strong PsiphonTunnelFeedback *strongSelf = weakSelf;
+            if (strongSelf == nil) {
+                return;
+            }
+            __strong id<PsiphonTunnelLoggerDelegate> strongLogger = weakLogger;
+            if (strongLogger == nil) {
+                return;
+            }
+            if ([strongLogger respondsToSelector:@selector(onDiagnosticMessage:withTimestamp:)]) {
+                NSString *timestamp = [[PsiphonTunnel rfc3339Formatter] stringFromDate:[NSDate date]];
+                dispatch_sync(strongSelf->callbackQueue, ^{
+                    [strongLogger onDiagnosticMessage:message withTimestamp:timestamp];
+                });
+            }
+        };
+
+        NSError *err;
+        NSString *sessionID = [PsiphonTunnel generateSessionID:&err];
+        if (err != nil) {
+            [feedbackDelegate sendFeedbackCompleted:err];
+            return;
+        }
+
+        BOOL usingNoticeFiles = FALSE;
+        BOOL tunnelWholeDevice = FALSE;
+
+        NSString *psiphonConfig = [PsiphonTunnel buildPsiphonConfig:feedbackConfigJson
+                                                   usingNoticeFiles:&usingNoticeFiles
+                                                  tunnelWholeDevice:&tunnelWholeDevice
+                                                          sessionID:sessionID
+                                                         logMessage:logMessage
+                                                              error:&err];
+        if (err != nil) {
+            NSError *outError = [NSError errorWithDomain:PsiphonTunnelErrorDomain
+                                                    code:PsiphonTunnelErrorCodeConfigError
+                                                userInfo:@{NSLocalizedDescriptionKey:@"Error building config",
+                                                           NSUnderlyingErrorKey:err}];
+            dispatch_sync(self->callbackQueue, ^{
+                [feedbackDelegate sendFeedbackCompleted:outError];
+            });
+            return;
+        } else if (psiphonConfig == nil) {
+            // Should never happen.
+            NSError *err = [NSError errorWithDomain:PsiphonTunnelErrorDomain
+                                               code:PsiphonTunnelErrorCodeConfigError
+                                           userInfo:@{NSLocalizedDescriptionKey:@"Error built config nil"}];
+            dispatch_sync(self->callbackQueue, ^{
+                [feedbackDelegate sendFeedbackCompleted:err];
+            });
+            return;
+        }
+
+        void (^sendFeedbackCompleted)(NSError * _Nonnull) = ^void(NSError * _Nonnull err) {
+            __strong PsiphonTunnelFeedback *strongSelf = weakSelf;
+            if (strongSelf == nil) {
+                return;
+            }
+            __strong id<PsiphonTunnelFeedbackDelegate> strongFeedbackDelegate = weakFeedbackDelegate;
+            if (strongFeedbackDelegate == nil) {
+                return;
+            }
+
+            NSError *outError = nil;
+
+            if (err != nil) {
+                outError = [NSError errorWithDomain:PsiphonTunnelErrorDomain
+                                               code:PsiphonTunnelErrorCodeSendFeedbackError
+                                           userInfo:@{NSLocalizedDescriptionKey:@"Error sending feedback",
+                                                      NSUnderlyingErrorKey:err}];
+            }
+            dispatch_sync(strongSelf->callbackQueue, ^{
+                [strongFeedbackDelegate sendFeedbackCompleted:outError];
+            });
+        };
+
+        PsiphonProviderFeedbackHandlerShim *innerFeedbackHandler =
+            [[PsiphonProviderFeedbackHandlerShim alloc] initWithHandler:sendFeedbackCompleted];
+
+        // Convert notice to a diagnostic message and then log it.
+        void (^logNotice)(NSString * _Nonnull) = ^void(NSString * _Nonnull noticeJSON) {
+            __strong PsiphonTunnelFeedback *strongSelf = weakSelf;
+            if (strongSelf == nil) {
+                return;
+            }
+            __strong id<PsiphonTunnelLoggerDelegate> strongLogger = weakLogger;
+            if (strongLogger == nil) {
+                return;
+            }
+            if ([strongLogger respondsToSelector:@selector(onDiagnosticMessage:withTimestamp:)]) {
+
+                __block NSDictionary *notice = nil;
+                id block = ^(id obj, BOOL *ignored) {
+                    if (ignored == nil || *ignored == YES) {
+                        return;
+                    }
+                    notice = (NSDictionary *)obj;
+                };
+
+                id eh = ^(NSError *err) {
+                    notice = nil;
+                    logMessage([NSString stringWithFormat: @"Notice JSON parse failed: %@", err.description]);
+                };
+
+                id parser = [SBJson4Parser parserWithBlock:block allowMultiRoot:NO unwrapRootArray:NO errorHandler:eh];
+                [parser parse:[noticeJSON dataUsingEncoding:NSUTF8StringEncoding]];
+
+                if (notice == nil) {
+                    return;
+                }
+
+                NSString *noticeType = notice[@"noticeType"];
+                if (noticeType == nil) {
+                    logMessage(@"Notice missing noticeType");
+                    return;
+                }
+
+                NSDictionary *data = notice[@"data"];
+                if (data == nil) {
+                    return;
+                }
+
+                NSString *dataStr = [[[SBJson4Writer alloc] init] stringWithObject:data];
+                NSString *timestampStr = notice[@"timestamp"];
+                if (timestampStr == nil) {
+                    return;
+                }
+
+                NSString *diagnosticMessage = [NSString stringWithFormat:@"%@: %@", noticeType, dataStr];
+                dispatch_sync(strongSelf->callbackQueue, ^{
+                    [strongLogger onDiagnosticMessage:diagnosticMessage withTimestamp:timestampStr];
+                });
+            }
+        };
+
+        PsiphonProviderNoticeHandlerShim *noticeHandler =
+            [[PsiphonProviderNoticeHandlerShim alloc] initWithLogger:logNotice];
+
+        GoPsiStartSendFeedback(psiphonConfig, feedbackJson, uploadPath, innerFeedbackHandler, noticeHandler);
+    });
+}
+
+// See comment in header.
+- (void)stopSendFeedback {
+    dispatch_async(self->workQueue, ^{
+        GoPsiStopSendFeedback();
+    });
+}
+
+@end

+ 34 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Utils/Backups.h

@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2019, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface Backups : NSObject
+
+/// Excludes the target file from application backups made by iCloud and iTunes.
+/// If NO is returned, the file was not successfully excluded from backup and the error is populated.
+/// @param filePath Path at which the file exists.
+/// @param err If non-nil, contains the error encountered when attempting to exclude the file from backup.
++ (BOOL)excludeFileFromBackup:(NSString*)filePath err:(NSError * _Nullable *)err;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 38 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Utils/Backups.m

@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2019, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#import "Backups.h"
+
+@implementation Backups
+
+// See comment in header
++ (BOOL)excludeFileFromBackup:(NSString*)filePath err:(NSError**)err {
+    *err = nil;
+
+    // The URL must be of the file scheme ("file://"), otherwise the `setResourceValue:forKey:error`
+    // operation will silently fail with: "CFURLCopyResourcePropertyForKey failed because passed URL
+    // no scheme".
+    NSURL *urlWithScheme = [NSURL fileURLWithPath:filePath];
+
+    return [urlWithScheme setResourceValue:[NSNumber numberWithBool:YES]
+                                    forKey:NSURLIsExcludedFromBackupKey
+                                     error:err];
+}
+
+@end

+ 72 - 4
MobileLibrary/psi/psi.go

@@ -54,6 +54,10 @@ type PsiphonProvider interface {
 	GetNetworkID() string
 }
 
+type PsiphonProviderFeedbackHandler interface {
+	SendFeedbackCompleted(err error)
+}
+
 func NoticeUserLog(message string) {
 	psiphon.NoticeUserLog(message)
 }
@@ -294,12 +298,49 @@ func ImportExchangePayload(payload string) bool {
 	return controller.ImportExchangePayload(payload)
 }
 
-// Encrypt and upload feedback.
-func SendFeedback(
+var sendFeedbackMutex sync.Mutex
+var sendFeedbackCtx context.Context
+var stopSendFeedback context.CancelFunc
+var sendFeedbackWaitGroup *sync.WaitGroup
+
+// StartSendFeedback encrypts the provided diagnostics and then attempts to
+// upload the encrypted diagnostics to one of the feedback upload locations
+// supplied by the provided config or tactics.
+//
+// Returns immediately after starting the operation in a goroutine. The
+// operation has completed when SendFeedbackCompleted(error) is called on the
+// provided PsiphonProviderFeedbackHandler; if error is non-nil, then the
+// operation failed.
+//
+// Only one active upload is supported at a time. An ongoing upload will be
+// cancelled if this function is called again before it completes.
+//
+// Warnings:
+// - Should not be used with Start concurrently in the same process
+// - Start and StartSendFeedback both make an attempt to migrate persistent
+//   files from legacy locations in a one-time operation. If these functions
+//   are called in parallel, then there is a chance that the migration attempts
+//   could execute at the same time and result in errors in one, or both, of the
+//   migration operations.
+// - Calling StartSendFeedback or StopSendFeedback on the same call stack
+//   that the PsiphonProviderFeedbackHandler.SendFeedbackCompleted() callback
+//   is delivered on can cause a deadlock. I.E. the callback code must return
+//   so the wait group can complete and the lock acquired in StopSendFeedback
+//   can be released.
+func StartSendFeedback(
 	configJson,
 	diagnosticsJson,
 	uploadPath string,
-	noticeHandler PsiphonProviderNoticeHandler) error {
+	feedbackHandler PsiphonProviderFeedbackHandler,
+	noticeHandler PsiphonProviderNoticeHandler) {
+
+	// Cancel any ongoing uploads.
+	StopSendFeedback()
+
+	sendFeedbackMutex.Lock()
+	defer sendFeedbackMutex.Unlock()
+
+	sendFeedbackCtx, stopSendFeedback = context.WithCancel(context.Background())
 
 	// Unlike in Start, the provider is not wrapped in a newMutexPsiphonProvider
 	// or equivilent, as SendFeedback is not expected to be used in a memory
@@ -310,7 +351,34 @@ func SendFeedback(
 			noticeHandler.Notice(string(notice))
 		}))
 
-	return psiphon.SendFeedback(configJson, diagnosticsJson, uploadPath)
+	sendFeedbackWaitGroup = new(sync.WaitGroup)
+	sendFeedbackWaitGroup.Add(1)
+
+	go func() {
+		defer sendFeedbackWaitGroup.Done()
+		err := psiphon.SendFeedback(sendFeedbackCtx, configJson, diagnosticsJson, uploadPath)
+		feedbackHandler.SendFeedbackCompleted(err)
+	}()
+}
+
+// StopSendFeedback interrupts an in-progress feedback upload operation
+// started with `StartSendFeedback`.
+//
+// Warning: should not be used with Start concurrently in the same process.
+func StopSendFeedback() {
+
+	sendFeedbackMutex.Lock()
+	defer sendFeedbackMutex.Unlock()
+
+	if stopSendFeedback != nil {
+		stopSendFeedback()
+		sendFeedbackWaitGroup.Wait()
+		sendFeedbackCtx = nil
+		stopSendFeedback = nil
+		sendFeedbackWaitGroup = nil
+		// Allow the notice receiver to be deallocated.
+		psiphon.SetNoticeWriter(os.Stderr)
+	}
 }
 
 // Get build info from tunnel-core

+ 18 - 11
psiphon/feedback.go

@@ -32,6 +32,7 @@ import (
 	"crypto/x509"
 	"encoding/base64"
 	"encoding/json"
+	stdlibErrors "errors"
 	"net/http"
 	"net/url"
 	"path"
@@ -97,7 +98,7 @@ func encryptFeedback(diagnosticsJson, b64EncodedPublicKey string) ([]byte, error
 
 // Encrypt feedback and upload to server. If upload fails
 // the routine will sleep and retry multiple times.
-func SendFeedback(configJson, diagnosticsJson, uploadPath string) error {
+func SendFeedback(ctx context.Context, configJson, diagnosticsJson, uploadPath string) error {
 
 	config, err := LoadConfig([]byte(configJson))
 	if err != nil {
@@ -112,11 +113,11 @@ func SendFeedback(configJson, diagnosticsJson, uploadPath string) error {
 	p := config.GetClientParameters().Get()
 	timeout := p.Duration(parameters.FeedbackTacticsWaitPeriod)
 	p.Close()
-	ctx, cancelFunc := context.WithTimeout(context.Background(), timeout)
+	getTacticsCtx, cancelFunc := context.WithTimeout(ctx, timeout)
 	defer cancelFunc()
 	// Note: GetTactics will fail silently if the datastore used for retrieving
 	// and storing tactics is opened by another process.
-	GetTactics(ctx, config)
+	GetTactics(getTacticsCtx, config)
 
 	// Get the latest client parameters
 	p = config.GetClientParameters().Get()
@@ -158,13 +159,13 @@ func SendFeedback(configJson, diagnosticsJson, uploadPath string) error {
 			return errors.Trace(err)
 		}
 
-		ctx, cancelFunc := context.WithTimeout(
-			context.Background(),
+		feedbackUploadCtx, cancelFunc := context.WithTimeout(
+			ctx,
 			feedbackUploadTimeout)
 		defer cancelFunc()
 
 		client, err := MakeUntunneledHTTPClient(
-			ctx,
+			feedbackUploadCtx,
 			config,
 			untunneledDialConfig,
 			nil,
@@ -180,7 +181,7 @@ func SendFeedback(configJson, diagnosticsJson, uploadPath string) error {
 
 		parsedURL.Path = path.Join(parsedURL.Path, uploadPath, uploadId)
 
-		request, err := http.NewRequestWithContext(ctx, "PUT", parsedURL.String(), bytes.NewBuffer(secureFeedback))
+		request, err := http.NewRequestWithContext(feedbackUploadCtx, "PUT", parsedURL.String(), bytes.NewBuffer(secureFeedback))
 		if err != nil {
 			return errors.Trace(err)
 		}
@@ -193,12 +194,18 @@ func SendFeedback(configJson, diagnosticsJson, uploadPath string) error {
 		err = uploadFeedback(client, request)
 		cancelFunc()
 		if err != nil {
-			NoticeWarning("uploadFeedback failed: %s", errors.Trace(err))
+			if stdlibErrors.Is(err, context.Canceled) {
+				return errors.TraceMsg(err, "feedback upload interrupted")
+			}
 			// Do not sleep after the last attempt
 			if i+1 < feedbackUploadMaxRetries {
-				time.Sleep(
-					prng.Period(
-						feedbackUploadMinRetryDelay, feedbackUploadMaxRetryDelay))
+				timeUntilRetry := prng.Period(feedbackUploadMinRetryDelay, feedbackUploadMaxRetryDelay)
+				NoticeWarning("uploadFeedback failed: %s, retry in %.0fs", errors.Trace(err), timeUntilRetry.Seconds())
+				select {
+				case <-ctx.Done():
+					return errors.TraceNew("feedback upload interrupted")
+				case <-time.After(timeUntilRetry):
+				}
 			}
 		} else {
 			break