1
0
Fork 0
mirror of https://github.com/syncthing/syncthing-android.git synced 2024-11-26 06:11:19 +00:00

Fix recurring device, folder accept notifications, add ignore action (fixes #679) (#1177)

This commit is contained in:
Catfriend1 2018-07-07 20:35:18 +02:00 committed by Audrius Butkevicius
parent b9f45f5162
commit 53aec6a313
9 changed files with 222 additions and 64 deletions

View file

@ -48,8 +48,12 @@ import static com.nutomic.syncthingandroid.util.Compression.METADATA;
*/ */
public class DeviceActivity extends SyncthingActivity implements View.OnClickListener { public class DeviceActivity extends SyncthingActivity implements View.OnClickListener {
public static final String EXTRA_NOTIFICATION_ID =
"com.nutomic.syncthingandroid.activities.DeviceActivity.NOTIFICATION_ID";
public static final String EXTRA_DEVICE_ID = public static final String EXTRA_DEVICE_ID =
"com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_ID"; "com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_ID";
public static final String EXTRA_DEVICE_NAME =
"com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_NAME";
public static final String EXTRA_IS_CREATE = public static final String EXTRA_IS_CREATE =
"com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE"; "com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE";
@ -215,6 +219,7 @@ public class DeviceActivity extends SyncthingActivity implements View.OnClickLis
super.onDestroy(); super.onDestroy();
SyncthingService syncthingService = getService(); SyncthingService syncthingService = getService();
if (syncthingService != null) { if (syncthingService != null) {
syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0));
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
} }
mIdView.removeTextChangedListener(mIdTextWatcher); mIdView.removeTextChangedListener(mIdTextWatcher);
@ -253,7 +258,10 @@ public class DeviceActivity extends SyncthingActivity implements View.OnClickLis
} }
private void onServiceConnected() { private void onServiceConnected() {
getService().registerOnServiceStateChangeListener(this::onServiceStateChange); Log.v(TAG, "onServiceConnected");
SyncthingService syncthingService = (SyncthingService) getService();
syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0));
syncthingService.registerOnServiceStateChangeListener(this::onServiceStateChange);
} }
/** /**
@ -394,7 +402,7 @@ public class DeviceActivity extends SyncthingActivity implements View.OnClickLis
private void initDevice() { private void initDevice() {
mDevice = new Device(); mDevice = new Device();
mDevice.name = ""; mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
mDevice.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID); mDevice.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID);
mDevice.addresses = DYNAMIC_ADDRESS; mDevice.addresses = DYNAMIC_ADDRESS;
mDevice.compression = METADATA.getValue(this); mDevice.compression = METADATA.getValue(this);

View file

@ -54,8 +54,10 @@ import static com.nutomic.syncthingandroid.service.SyncthingService.State.ACTIVE
public class FolderActivity extends SyncthingActivity public class FolderActivity extends SyncthingActivity
implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnServiceStateChangeListener { implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnServiceStateChangeListener {
public static final String EXTRA_NOTIFICATION_ID =
"com.nutomic.syncthingandroid.activities.FolderActivity.NOTIFICATION_ID";
public static final String EXTRA_IS_CREATE = public static final String EXTRA_IS_CREATE =
"com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE"; "com.nutomic.syncthingandroid.activities.FolderActivity.IS_CREATE";
public static final String EXTRA_FOLDER_ID = public static final String EXTRA_FOLDER_ID =
"com.nutomic.syncthingandroid.activities.FolderActivity.FOLDER_ID"; "com.nutomic.syncthingandroid.activities.FolderActivity.FOLDER_ID";
public static final String EXTRA_FOLDER_LABEL = public static final String EXTRA_FOLDER_LABEL =
@ -237,6 +239,7 @@ public class FolderActivity extends SyncthingActivity
super.onDestroy(); super.onDestroy();
SyncthingService syncthingService = getService(); SyncthingService syncthingService = getService();
if (syncthingService != null) { if (syncthingService != null) {
syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0));
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
} }
mLabelView.removeTextChangedListener(mTextWatcher); mLabelView.removeTextChangedListener(mTextWatcher);
@ -272,7 +275,10 @@ public class FolderActivity extends SyncthingActivity
*/ */
@Override @Override
public void onServiceConnected() { public void onServiceConnected() {
getService().registerOnServiceStateChangeListener(this); Log.v(TAG, "onServiceConnected");
SyncthingService syncthingService = (SyncthingService) getService();
syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0));
syncthingService.registerOnServiceStateChangeListener(this);
} }
@Override @Override

View file

@ -4,11 +4,12 @@ import java.util.List;
public class Config { public class Config {
public int version; public int version;
public String[] ignoredDevices;
public List<Device> devices; public List<Device> devices;
public List<Folder> folders; public List<Folder> folders;
public Gui gui; public Gui gui;
public Options options; public Options options;
public List<String> ignoredFolders;
public List<String> ignoredDevices;
public class Gui { public class Gui {
public boolean enabled; public boolean enabled;

View file

@ -94,9 +94,6 @@ public class EventProcessor implements Runnable, RestApi.OnReceiveEventListener
*/ */
@Override @Override
public void onEvent(Event event) { public void onEvent(Event event) {
String deviceId;
String folderId;
switch (event.type) { switch (event.type) {
case "ConfigSaved": case "ConfigSaved":
if (mApi != null) { if (mApi != null) {
@ -105,56 +102,26 @@ public class EventProcessor implements Runnable, RestApi.OnReceiveEventListener
} }
break; break;
case "DeviceRejected": case "DeviceRejected":
deviceId = (String) event.data.get("device"); onDeviceRejected(
Log.d(TAG, "Unknown device " + deviceId + " wants to connect"); (String) event.data.get("device"), // deviceId
(String) event.data.get("name") // deviceName
Intent intent = new Intent(mContext, DeviceActivity.class) );
.putExtra(DeviceActivity.EXTRA_IS_CREATE, true)
.putExtra(DeviceActivity.EXTRA_DEVICE_ID, deviceId);
// HACK: Use a random, deterministic ID to make multiple PendingIntents
// distinguishable
int requestCode = deviceId.hashCode();
PendingIntent pi = PendingIntent.getActivity(mContext, requestCode, intent, 0);
String title = mContext.getString(R.string.device_rejected,
deviceId.substring(0, 7));
notify(title, pi);
break; break;
case "FolderCompletion": case "FolderCompletion":
deviceId = (String) event.data.get("device");
folderId = (String) event.data.get("folder");
CompletionInfo completionInfo = new CompletionInfo(); CompletionInfo completionInfo = new CompletionInfo();
completionInfo.completion = (Double) event.data.get("completion"); completionInfo.completion = (Double) event.data.get("completion");
mApi.setCompletionInfo(deviceId, folderId, completionInfo); mApi.setCompletionInfo(
(String) event.data.get("device"), // deviceId
(String) event.data.get("folder"), // folderId
completionInfo
);
break; break;
case "FolderRejected": case "FolderRejected":
deviceId = (String) event.data.get("device"); onFolderRejected(
folderId = (String) event.data.get("folder"); (String) event.data.get("device"), // deviceId
String folderLabel = (String) event.data.get("folderLabel"); (String) event.data.get("folder"), // folderId
Log.d(TAG, "Device " + deviceId + " wants to share folder " + folderId); (String) event.data.get("folderLabel") // folderLabel
);
boolean isNewFolder = Stream.of(mApi.getFolders())
.noneMatch(f -> f.id.equals(folderId));
intent = new Intent(mContext, FolderActivity.class)
.putExtra(FolderActivity.EXTRA_IS_CREATE, isNewFolder)
.putExtra(FolderActivity.EXTRA_DEVICE_ID, deviceId)
.putExtra(FolderActivity.EXTRA_FOLDER_ID, folderId)
.putExtra(FolderActivity.EXTRA_FOLDER_LABEL, folderLabel);
// HACK: Use a random, deterministic ID to make multiple PendingIntents
// distinguishable
requestCode = (deviceId + folderId + folderLabel).hashCode();
pi = PendingIntent.getActivity(mContext, requestCode, intent, 0);
String deviceName = null;
for (Device d : mApi.getDevices(false)) {
if (d.deviceID.equals(deviceId))
deviceName = d.getDisplayName();
}
title = mContext.getString(R.string.folder_rejected, deviceName,
folderLabel.isEmpty() ? folderId : folderLabel + " (" + folderId + ")");
notify(title, pi);
break; break;
case "ItemFinished": case "ItemFinished":
String folder = (String) event.data.get("folder"); String folder = (String) event.data.get("folder");
@ -242,10 +209,78 @@ public class EventProcessor implements Runnable, RestApi.OnReceiveEventListener
} }
} }
private void notify(String text, PendingIntent pi) { private void onDeviceRejected(String deviceId, String deviceName) {
// HACK: Use a random, deterministic ID between 1000 and 2000 to avoid duplicate if (deviceId == null) {
// notifications. return;
int notificationId = 1000 + text.hashCode() % 1000; }
mNotificationHandler.showEventNotification(text, pi, notificationId); Log.d(TAG, "Unknown device " + deviceName + "(" + deviceId + ") wants to connect");
String title = mContext.getString(R.string.device_rejected,
deviceName.isEmpty() ? deviceId.substring(0, 7) : deviceName);
int notificationId = mNotificationHandler.getNotificationIdFromText(title);
// Prepare "accept" action.
Intent intentAccept = new Intent(mContext, DeviceActivity.class)
.putExtra(DeviceActivity.EXTRA_NOTIFICATION_ID, notificationId)
.putExtra(DeviceActivity.EXTRA_IS_CREATE, true)
.putExtra(DeviceActivity.EXTRA_DEVICE_ID, deviceId)
.putExtra(DeviceActivity.EXTRA_DEVICE_NAME, deviceName);
PendingIntent piAccept = PendingIntent.getActivity(mContext, notificationId,
intentAccept, PendingIntent.FLAG_UPDATE_CURRENT);
// Prepare "ignore" action.
Intent intentIgnore = new Intent(mContext, SyncthingService.class)
.putExtra(SyncthingService.EXTRA_NOTIFICATION_ID, notificationId)
.putExtra(SyncthingService.EXTRA_DEVICE_ID, deviceId);
intentIgnore.setAction(SyncthingService.ACTION_IGNORE_DEVICE);
PendingIntent piIgnore = PendingIntent.getService(mContext, 0,
intentIgnore, PendingIntent.FLAG_UPDATE_CURRENT);
// Show notification.
mNotificationHandler.showConsentNotification(notificationId, title, piAccept, piIgnore);
}
private void onFolderRejected(String deviceId, String folderId,
String folderLabel) {
if (deviceId == null || folderId == null) {
return;
}
Log.d(TAG, "Device " + deviceId + " wants to share folder " +
folderLabel + " (" + folderId + ")");
// Find the deviceName corresponding to the deviceId
String deviceName = null;
for (Device d : mApi.getDevices(false)) {
if (d.deviceID.equals(deviceId)) {
deviceName = d.getDisplayName();
break;
}
}
String title = mContext.getString(R.string.folder_rejected, deviceName,
folderLabel.isEmpty() ? folderId : folderLabel + " (" + folderId + ")");
int notificationId = mNotificationHandler.getNotificationIdFromText(title);
// Prepare "accept" action.
boolean isNewFolder = Stream.of(mApi.getFolders())
.noneMatch(f -> f.id.equals(folderId));
Intent intentAccept = new Intent(mContext, FolderActivity.class)
.putExtra(FolderActivity.EXTRA_NOTIFICATION_ID, notificationId)
.putExtra(FolderActivity.EXTRA_IS_CREATE, isNewFolder)
.putExtra(FolderActivity.EXTRA_DEVICE_ID, deviceId)
.putExtra(FolderActivity.EXTRA_FOLDER_ID, folderId)
.putExtra(FolderActivity.EXTRA_FOLDER_LABEL, folderLabel);
PendingIntent piAccept = PendingIntent.getActivity(mContext, notificationId,
intentAccept, PendingIntent.FLAG_UPDATE_CURRENT);
// Prepare "ignore" action.
Intent intentIgnore = new Intent(mContext, SyncthingService.class)
.putExtra(SyncthingService.EXTRA_NOTIFICATION_ID, notificationId)
.putExtra(SyncthingService.EXTRA_FOLDER_ID, folderId);
intentIgnore.setAction(SyncthingService.ACTION_IGNORE_FOLDER);
PendingIntent piIgnore = PendingIntent.getService(mContext, 0,
intentIgnore, PendingIntent.FLAG_UPDATE_CURRENT);
// Show notification.
mNotificationHandler.showConsentNotification(notificationId, title, piAccept, piIgnore);
} }
} }

View file

@ -10,6 +10,7 @@ import android.content.SharedPreferences;
import android.os.Build; import android.os.Build;
import android.support.annotation.StringRes; import android.support.annotation.StringRes;
import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat;
import android.util.Log;
import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.SyncthingApp;
@ -22,6 +23,7 @@ import javax.inject.Inject;
public class NotificationHandler { public class NotificationHandler {
private static final String TAG = "NotificationHandler";
private static final int ID_PERSISTENT = 1; private static final int ID_PERSISTENT = 1;
private static final int ID_PERSISTENT_WAITING = 4; private static final int ID_PERSISTENT_WAITING = 4;
private static final int ID_RESTART = 2; private static final int ID_RESTART = 2;
@ -178,17 +180,51 @@ public class NotificationHandler {
} }
} }
public void showEventNotification(String text, PendingIntent pi, int id) { /**
* Calculate a deterministic ID between 1000 and 2000 to avoid duplicate
* notification ids for different device, folder consent popups triggered
* by {@link EventProcessor}.
*/
public int getNotificationIdFromText(String text) {
return 1000 + text.hashCode() % 1000;
}
/**
* Closes a notification. Required after the user hit an action button.
*/
public void cancelConsentNotification(int notificationId) {
if (notificationId == 0) {
return;
}
Log.v(TAG, "Cancelling notification with id " + notificationId);
mNotificationManager.cancel(notificationId);
}
/**
* Used by {@link EventProcessor}
*/
public void showConsentNotification(int notificationId,
String text,
PendingIntent piAccept,
PendingIntent piIgnore) {
/**
* As we know the id for a specific notification text,
* we'll dismiss this notification as it may be outdated.
* This is also valid if the notification does not exist.
*/
mNotificationManager.cancel(notificationId);
Notification n = getNotificationBuilder(mInfoChannel) Notification n = getNotificationBuilder(mInfoChannel)
.setContentTitle(mContext.getString(R.string.app_name)) .setContentTitle(mContext.getString(R.string.app_name))
.setContentText(text) .setContentText(text)
.setStyle(new NotificationCompat.BigTextStyle() .setStyle(new NotificationCompat.BigTextStyle()
.bigText(text)) .bigText(text))
.setContentIntent(pi) .setContentIntent(piAccept)
.addAction(R.drawable.ic_stat_notify, mContext.getString(R.string.accept), piAccept)
.addAction(R.drawable.ic_stat_notify, mContext.getString(R.string.ignore), piIgnore)
.setSmallIcon(R.drawable.ic_stat_notify) .setSmallIcon(R.drawable.ic_stat_notify)
.setAutoCancel(true) .setAutoCancel(true)
.build(); .build();
mNotificationManager.notify(id, n); mNotificationManager.notify(notificationId, n);
} }
public void showStoragePermissionRevokedNotification() { public void showStoragePermissionRevokedNotification() {

View file

@ -242,6 +242,32 @@ public class RestApi {
} }
} }
/**
* Permanently ignore a device when it tries to connect.
* Ignored devices will not trigger the "DeviceRejected" event
* in {@link EventProcessor#onEvent}.
*/
public void ignoreDevice(String deviceId) {
if (!mConfig.ignoredDevices.contains(deviceId)) {
mConfig.ignoredDevices.add(deviceId);
sendConfig();
Log.d(TAG, "Ignored device [" + deviceId + "]");
}
}
/**
* Permanently ignore a folder share request.
* Ignored folders will not trigger the "FolderRejected" event
* in {@link EventProcessor#onEvent}.
*/
public void ignoreFolder(String folderId) {
if (!mConfig.ignoredFolders.contains(folderId)) {
mConfig.ignoredFolders.add(folderId);
sendConfig();
Log.d(TAG, "Ignored folder [" + folderId + "]");
}
}
/** /**
* Sends current config to Syncthing. * Sends current config to Syncthing.
* Will result in a "ConfigSaved" event. * Will result in a "ConfigSaved" event.

View file

@ -59,6 +59,36 @@ public class SyncthingService extends Service {
public static final String ACTION_REFRESH_NETWORK_INFO = public static final String ACTION_REFRESH_NETWORK_INFO =
"com.nutomic.syncthingandroid.service.SyncthingService.REFRESH_NETWORK_INFO"; "com.nutomic.syncthingandroid.service.SyncthingService.REFRESH_NETWORK_INFO";
/**
* Intent action to permanently ignore a device connection request.
*/
public static final String ACTION_IGNORE_DEVICE =
"com.nutomic.syncthingandroid.service.SyncthingService.IGNORE_DEVICE";
/**
* Intent action to permanently ignore a folder share request.
*/
public static final String ACTION_IGNORE_FOLDER =
"com.nutomic.syncthingandroid.service.SyncthingService.IGNORE_FOLDER";
/**
* Extra used together with ACTION_IGNORE_DEVICE, ACTION_IGNORE_FOLDER.
*/
public static final String EXTRA_NOTIFICATION_ID =
"com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_NOTIFICATION_ID";
/**
* Extra used together with ACTION_IGNORE_DEVICE
*/
public static final String EXTRA_DEVICE_ID =
"com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_ID";
/**
* Extra used together with ACTION_IGNORE_FOLDER
*/
public static final String EXTRA_FOLDER_ID =
"com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_ID";
public interface OnServiceStateChangeListener { public interface OnServiceStateChangeListener {
void onServiceStateChange(State currentState); void onServiceStateChange(State currentState);
} }
@ -204,6 +234,14 @@ public class SyncthingService extends Service {
}); });
} else if (ACTION_REFRESH_NETWORK_INFO.equals(intent.getAction())) { } else if (ACTION_REFRESH_NETWORK_INFO.equals(intent.getAction())) {
mDeviceStateHolder.updateShouldRunDecision(); mDeviceStateHolder.updateShouldRunDecision();
} else if (ACTION_IGNORE_DEVICE.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
// mApi is not null due to State.ACTIVE
mApi.ignoreDevice(intent.getStringExtra(EXTRA_DEVICE_ID));
mNotificationHandler.cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0));
} else if (ACTION_IGNORE_FOLDER.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
// mApi is not null due to State.ACTIVE
mApi.ignoreFolder(intent.getStringExtra(EXTRA_FOLDER_ID));
mNotificationHandler.cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0));
} }
return START_STICKY; return START_STICKY;
} }
@ -527,6 +565,10 @@ public class SyncthingService extends Service {
return mCurrentState; return mCurrentState;
} }
public NotificationHandler getNotificationHandler() {
return mNotificationHandler;
}
/** /**
* Exports the local config and keys to {@link Constants#EXPORT_PATH}. * Exports the local config and keys to {@link Constants#EXPORT_PATH}.
*/ */

View file

@ -25,8 +25,8 @@ Bitte melden Sie auftretende Probleme via GitHub.</string>
<string name="no">Nein</string> <string name="no">Nein</string>
<string name="open_website">Webseite öffnen</string> <string name="open_website">Webseite öffnen</string>
<string name="toast_write_storage_permission_required">Diese App benötigt Schreibzugriff auf den Gerätespeicher</string> <string name="toast_write_storage_permission_required">Diese App benötigt Schreibzugriff auf den Gerätespeicher</string>
<string name="device_rejected">Gerät %1$s möchte sich verbinden</string> <string name="device_rejected">Gerät \"%1$s\" möchte sich verbinden</string>
<string name="folder_rejected">Gerät %1$s möchte Verzeichnis %2$s teilen</string> <string name="folder_rejected">Gerät \"%1$s\" möchte Verzeichnis \"%2$s\" teilen</string>
<string name="dialog_disable_battery_optimization_title">Batterielaufzeit Optimierung</string> <string name="dialog_disable_battery_optimization_title">Batterielaufzeit Optimierung</string>
<string name="dialog_disable_battery_optimization_message">Android kann die Synchronisation nach einiger Zeit stoppen. Um dies zu verhindern kann der Energiesparmodus deaktiviert werden.\n\nEinige Geräte haben Apps vorinstalliert, welche Hintergrundaktivitäten unterbinden. Syncthing sollte deshalb auch in die Ausnahmeliste hinzugefügt werden.</string> <string name="dialog_disable_battery_optimization_message">Android kann die Synchronisation nach einiger Zeit stoppen. Um dies zu verhindern kann der Energiesparmodus deaktiviert werden.\n\nEinige Geräte haben Apps vorinstalliert, welche Hintergrundaktivitäten unterbinden. Syncthing sollte deshalb auch in die Ausnahmeliste hinzugefügt werden.</string>
<string name="dialog_disable_battery_optimization_later">Später</string> <string name="dialog_disable_battery_optimization_later">Später</string>

View file

@ -21,6 +21,10 @@ Please report any problems you encounter via Github.</string>
<!-- Generic texts used everywhere --> <!-- Generic texts used everywhere -->
<string name="generic_example">Example</string> <string name="generic_example">Example</string>
<string name="accept">Accept</string>
<string name="ignore">Ignore</string>
<!-- MainActivity --> <!-- MainActivity -->
@ -46,9 +50,9 @@ Please report any problems you encounter via Github.</string>
<string name="toast_write_storage_permission_required">Write storage permission is required for this app</string> <string name="toast_write_storage_permission_required">Write storage permission is required for this app</string>
<string name="device_rejected">Device %1$s wants to connect</string> <string name="device_rejected">Device \"%1$s\" wants to connect</string>
<string name="folder_rejected">Device %1$s wants to share folder %2$s</string> <string name="folder_rejected">Device \"%1$s\" wants to share folder \"%2$s\"</string>
<string name="dialog_disable_battery_optimization_title">Battery Optimization</string> <string name="dialog_disable_battery_optimization_title">Battery Optimization</string>
<string name="dialog_disable_battery_optimization_message">Android may stop synchronization after some time. To prevent this, turn off battery optimization.\n\nSome devices have additional task-killing apps preinstalled. You should add Syncthing to their whitelist, as well.</string> <string name="dialog_disable_battery_optimization_message">Android may stop synchronization after some time. To prevent this, turn off battery optimization.\n\nSome devices have additional task-killing apps preinstalled. You should add Syncthing to their whitelist, as well.</string>