package com.nutomic.syncthingandroid.syncthing;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.os.AsyncTask;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import com.nutomic.syncthingandroid.BuildConfig;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.activities.RestartActivity;
import com.nutomic.syncthingandroid.http.GetTask;
import com.nutomic.syncthingandroid.http.PostTask;
import com.nutomic.syncthingandroid.util.FolderObserver;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.Serializable;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Provides functions to interact with the syncthing REST API.
*/
public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
FolderObserver.OnFolderFileChangeListener {
private static final String TAG = "RestApi";
/**
* Parameter for {@link #getValue} or {@link #setValue} referring to "options" config item.
*/
public static final String TYPE_OPTIONS = "options";
/**
* Parameter for {@link #getValue} or {@link #setValue} referring to "gui" config item.
*/
public static final String TYPE_GUI = "gui";
/**
* The name of the HTTP header used for the syncthing API key.
*/
public static final String HEADER_API_KEY = "X-API-Key";
/**
* Key of the map element containing connection info for the local device, in the return
* value of {@link #getConnections}
*/
public static final String TOTAL_STATS = "total";
public static final int USAGE_REPORTING_UNDECIDED = 0;
public static final int USAGE_REPORTING_ACCEPTED = 2;
public static final int USAGE_REPORTING_DENIED = -1;
private static final List<Integer> USAGE_REPORTING_DECIDED =
Arrays.asList(USAGE_REPORTING_ACCEPTED, USAGE_REPORTING_DENIED);
public static class Device implements Serializable {
public List<String> addresses;
public String name;
public String deviceID;
public String compression;
public boolean introducer;
}
public static class SystemInfo {
public long alloc;
public double cpuPercent;
public int extAnnounceConnected; // Number of connected announce servers.
public int extAnnounceTotal; // Total number of configured announce servers.
public int goroutines;
public String myID;
public long sys;
}
public static class SystemVersion {
@SerializedName("arch")
public String architecture;
@SerializedName("codename")
public String codename;
@SerializedName("longVersion")
public String longVersion;
@SerializedName("os")
public String os;
@SerializedName("version")
public String version;
}
public static class Folder implements Serializable {
public String path;
public String label;
public String id;
public String invalid;
public List<String> deviceIds;
public boolean readOnly;
public int rescanIntervalS;
public Versioning versioning;
}
public static class Versioning implements Serializable {
protected final Map<String, String> mParams = new HashMap<>();
public String getType() {
return "";
}
public Map<String, String> getParams() {
return mParams;
}
}
public static class SimpleVersioning extends Versioning {
@Override
public String getType() {
return "simple";
}
public void setParams(int keep) {
mParams.put("keep", Integer.toString(keep));
}
}
public static class Connection {
public String at;
public long inBytesTotal;
public long outBytesTotal;
public long inBits;
public long outBits;
public String address;
public String clientVersion;
public int completion;
public boolean connected;
}
public static class Model {
public long globalBytes;
public long globalDeleted;
public long globalFiles;
public long localBytes;
public long localDeleted;
public long localFiles;
public long inSyncBytes;
public long inSyncFiles;
public long needBytes;
public long needFiles;
public long needDeletes;
public String state;
public String invalid;
}
public interface OnConfigChangedListener {
void onConfigChanged();
}
private final Context mContext;
private String mVersion;
private final URL mUrl;
private final String mApiKey;
private final String mHttpsCertPath;
private JSONObject mConfig;
private String mLocalDeviceId;
private boolean mRestartPostponed = false;
/**
* Stores the result of the last successful request to {@link GetTask#URI_CONNECTIONS},
* or an empty Map.
*/
private Map<String, Connection> mPreviousConnections = new HashMap<>();
/**
* Stores the timestamp of the last successful request to {@link GetTask#URI_CONNECTIONS}.
*/
private long mPreviousConnectionTime = 0;
/**
* Stores the latest result of {@link #getModel(String, OnReceiveModelListener)} for each folder,
* for calculating device percentage in {@link #getConnections(OnReceiveConnectionsListener)}.
*/
private final HashMap<String, Model> mCachedModelInfo = new HashMap<>();
/**
* Stores a hash map to resolve folders to paths for events.
*/
private final Map<String, String> mCacheFolderPathLookup = new HashMap<>();
public RestApi(Context context, URL url, String apiKey, OnApiAvailableListener apiListener,
OnConfigChangedListener configListener) {
mContext = context;
mUrl = url;
mApiKey = apiKey;
mHttpsCertPath = mContext.getFilesDir() + "/" + SyncthingService.HTTPS_CERT_FILE;
mOnApiAvailableListener = apiListener;
mOnConfigChangedListener = configListener;
}
/**
* Number of previous calls to {@link #tryIsAvailable()}.
*/
private final AtomicInteger mAvailableCount = new AtomicInteger(0);
/**
* Number of asynchronous calls performed in {@link #onWebGuiAvailable()}.
*/
private static final int TOTAL_STARTUP_CALLS = 3;
public interface OnApiAvailableListener {
public void onApiAvailable();
}
private final OnApiAvailableListener mOnApiAvailableListener;
private final OnConfigChangedListener mOnConfigChangedListener;
/**
* Gets local device ID, syncthing version and config, then calls all OnApiAvailableListeners.
*/
@Override
public void onWebGuiAvailable() {
mAvailableCount.set(0);
new GetTask(mUrl, GetTask.URI_VERSION, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
if (s == null)
return;
try {
JSONObject json = new JSONObject(s);
mVersion = json.getString("version");
Log.i(TAG, "Syncthing version is " + mVersion);
tryIsAvailable();
}
catch (JSONException e) {
Log.w(TAG, "Failed to parse config", e);
}
}
}.execute();
new GetTask(mUrl, GetTask.URI_CONFIG, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String config) {
try {
mConfig = new JSONObject(config);
tryIsAvailable();
} catch (JSONException e) {
Log.w(TAG, "Failed to parse config", e);
}
}
}.execute();
getSystemInfo(info -> {
mLocalDeviceId = info.myID;
tryIsAvailable();
});
}
/**
* Increments mAvailableCount by one, and, if it reached TOTAL_STARTUP_CALLS,
* calls {@link SyncthingService#onApiChange()}.
*/
private void tryIsAvailable() {
int value = mAvailableCount.incrementAndGet();
if (BuildConfig.DEBUG && value > TOTAL_STARTUP_CALLS) {
throw new AssertionError("Too many startup calls");
}
if (value == TOTAL_STARTUP_CALLS) {
mOnApiAvailableListener.onApiAvailable();
}
}
/**
* Returns the version name, or a (text) error message on failure.
*/
public String getVersion() {
return mVersion;
}
/**
* Stops syncthing and cancels notification. For use by {@link SyncthingService}.
*/
public void shutdown() {
// Happens in unit tests.
if (mContext == null)
return;
NotificationManager nm = (NotificationManager)
mContext.getSystemService(Context.NOTIFICATION_SERVICE);
nm.cancel(RestartActivity.NOTIFICATION_RESTART);
mRestartPostponed = false;
}
/**
* Gets a value from config,
*
* Booleans are returned as {@link }Boolean#toString}, arrays as space seperated string.
*
* @param name {@link #TYPE_OPTIONS} or {@link #TYPE_GUI}
* @param key The key to read from.
* @return The value as a String, or null on failure.
*/
public String getValue(String name, String key) {
// Happens if this functions is called before class is fully initialized.
if (mConfig == null)
return "";
try {
Object value = mConfig.getJSONObject(name).get(key);
return (value instanceof JSONArray)
? ((JSONArray) value).join(", ").replace("\"", "").replace("\\", "")
: value.toString();
} catch (JSONException e) {
Log.w(TAG, "Failed to get value for " + key, e);
return "";
}
}
/**
* Sets a value to config and sends it via Rest API.
* <p/>
* Booleans must be passed as {@link Boolean}, arrays as space seperated string
* with isArray true.
*
* @param name {@link #TYPE_OPTIONS} or {@link #TYPE_GUI}
* @param key The key to write to.
* @param value The new value to set, either String, Boolean or Integer.
* @param isArray True if value is a space seperated String that should be converted to array.
*/
public <T> void setValue(String name, String key, T value, boolean isArray, Activity activity) {
try {
mConfig.getJSONObject(name).put(key, (isArray)
? new JSONArray(Arrays.asList(((String) value).split(",")))
: value);
requireRestart(activity);
} catch (JSONException e) {
Log.w(TAG, "Failed to set value for " + key, e);
}
}
private List<String> jsonToList(JSONArray array) throws JSONException {
ArrayList<String> list = new ArrayList<>(array.length());
for (int i = 0; i < array.length(); i++) {
list.add(array.getString(i));
}
return list;
}
/**
* Either shows a restart dialog, or only updates the config, depending on
* {@link #mRestartPostponed}.
*/
public void requireRestart(Activity activity) {
if (mRestartPostponed) {
new PostTask(mUrl, PostTask.URI_CONFIG, mHttpsCertPath, mApiKey).execute(mConfig.toString());
} else {
activity.startActivity(new Intent(mContext, RestartActivity.class));
}
mOnConfigChangedListener.onConfigChanged();
}
/**
* Sends the current config to Syncthing and restarts it.
*
* This executes a restart immediately, and does not show a dialog.
*/
public void updateConfig() {
new PostTask(mUrl, PostTask.URI_CONFIG, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(Boolean b) {
mContext.startService(new Intent(mContext, SyncthingService.class)
.setAction(SyncthingService.ACTION_RESTART));
}
}.execute(mConfig.toString());
}
/**
* Returns a list of all existing devices.
*
* @param includeLocal True if the local device should be included in the result.
*/
public List<Device> getDevices(boolean includeLocal) {
if (mConfig == null)
return new ArrayList<>();
try {
JSONArray devices = mConfig.getJSONArray("devices");
List<Device> ret = new ArrayList<>(devices.length());
for (int i = 0; i < devices.length(); i++) {
JSONObject json = devices.getJSONObject(i);
Device n = new Device();
n.addresses = jsonToList(json.optJSONArray("addresses"));
n.name = json.getString("name");
n.deviceID = json.getString("deviceID");
n.compression = json.getString("compression");
n.introducer = json.getBoolean("introducer");
// Use null-save check because mLocalDeviceId might not be initialized yet.
// Should be replaced with Object.equals() when that becomes available in Android.
boolean sameId = (mLocalDeviceId == n.deviceID) ||
(mLocalDeviceId != null && mLocalDeviceId.equals(n.deviceID));
if (includeLocal || !sameId) {
ret.add(n);
}
}
return ret;
} catch (JSONException e) {
Log.w(TAG, "Failed to read devices", e);
return new ArrayList<>();
}
}
/**
* Result listener for {@link #getSystemInfo(OnReceiveSystemInfoListener)}.
*/
public interface OnReceiveSystemInfoListener {
public void onReceiveSystemInfo(SystemInfo info);
}
/**
* Result listener for {@link #getSystemVersion(OnReceiveSystemVersionListener)}.
*/
public interface OnReceiveSystemVersionListener {
void onReceiveSystemVersion(SystemVersion version);
}
/**
* Requests and parses information about current system status and resource usage.
*
* @param listener Callback invoked when the result is received.
*/
public void getSystemInfo(final OnReceiveSystemInfoListener listener) {
new GetTask(mUrl, GetTask.URI_SYSTEM, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
if (s == null)
return;
try {
JSONObject system = new JSONObject(s);
SystemInfo si = new SystemInfo();
si.alloc = system.getLong("alloc");
si.cpuPercent = system.getDouble("cpuPercent");
if (system.has("discoveryEnabled")) {
si.extAnnounceTotal = system.getInt("discoveryMethods");
si.extAnnounceConnected =
si.extAnnounceTotal - system.getJSONObject("discoveryErrors").length();
} else {
si.extAnnounceTotal = 0;
si.extAnnounceConnected = 0;
}
si.goroutines = system.getInt("goroutines");
si.myID = system.getString("myID");
si.sys = system.getLong("sys");
listener.onReceiveSystemInfo(si);
} catch (JSONException e) {
Log.w(TAG, "Failed to read system info", e);
}
}
}.execute();
}
/**
* Requests and parses system version information.
*
* @param listener Callback invoked when the result is received.
*/
public void getSystemVersion(final OnReceiveSystemVersionListener listener) {
new GetTask(mUrl, GetTask.URI_VERSION, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String response) {
if (response == null) {
return;
}
try {
SystemVersion systemVersion = new Gson().fromJson(response, SystemVersion.class);
listener.onReceiveSystemVersion(systemVersion);
} catch (JsonSyntaxException e) {
Log.w(TAG, "Failed to read system info", e);
}
}
}.execute();
}
/**
* Returns a list of all existing folders.
*/
public List<Folder> getFolders() {
if (mConfig == null)
return new ArrayList<>();
List<Folder> ret;
try {
JSONArray folders = mConfig.getJSONArray("folders");
ret = new ArrayList<>(folders.length());
for (int i = 0; i < folders.length(); i++) {
JSONObject json = folders.getJSONObject(i);
Folder r = new Folder();
r.path = json.getString("path");
r.label = json.getString("label");
r.id = json.getString("id");
// TODO: Field seems to be missing sometimes.
// https://github.com/syncthing/syncthing-android/issues/291
r.invalid = json.optString("invalid");
r.deviceIds = new ArrayList<>();
JSONArray devices = json.getJSONArray("devices");
for (int j = 0; j < devices.length(); j++) {
JSONObject n = devices.getJSONObject(j);
r.deviceIds.add(n.getString("deviceID"));
}
r.readOnly = json.getString("type").equals("readonly");
r.rescanIntervalS = json.getInt("rescanIntervalS");
JSONObject versioning = json.getJSONObject("versioning");
if (versioning.getString("type").equals("simple")) {
SimpleVersioning sv = new SimpleVersioning();
JSONObject params = versioning.getJSONObject("params");
sv.setParams(params.getInt("keep"));
r.versioning = sv;
} else {
r.versioning = new Versioning();
}
ret.add(r);
}
} catch (JSONException e) {
Log.w(TAG, "Failed to read devices", e);
return new ArrayList<>();
}
return ret;
}
/**
* Converts a number of bytes to a human readable file size (eg 3.5 GiB).
*
* Based on http://stackoverflow.com/a/5599842
*/
public static String readableFileSize(Context context, long bytes) {
final String[] units = context.getResources().getStringArray(R.array.file_size_units);
if (bytes <= 0) return "0 " + units[0];
int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024));
return new DecimalFormat("#,##0.#")
.format(bytes / Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}
/**
* Converts a number of bytes to a human readable transfer rate in bytes per second
* (eg 100 KiB/s).
*
* Based on http://stackoverflow.com/a/5599842
*/
public static String readableTransferRate(Context context, long bits) {
final String[] units = context.getResources().getStringArray(R.array.transfer_rate_units);
long bytes = bits / 8;
if (bytes <= 0) return "0 " + units[0];
int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024));
return new DecimalFormat("#,##0.#")
.format(bytes / Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}
/**
* Listener for {@link #getConnections}.
*/
public interface OnReceiveConnectionsListener {
/**
* @param connections Map from Device id to {@link Connection}.
* <p/>
* NOTE: The parameter connections is cached internally. Do not modify it or
* any of its contents.
*/
public void onReceiveConnections(Map<String, Connection> connections);
}
/**
* Returns connection info for the local device and all connected devices.
* <p/>
* Use the key {@link #TOTAL_STATS} to get connection info for the local device.
*/
public void getConnections(final OnReceiveConnectionsListener listener) {
new GetTask(mUrl, GetTask.URI_CONNECTIONS, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
if (s == null)
return;
Long now = System.currentTimeMillis();
Long timeElapsed = (now - mPreviousConnectionTime) / 1000;
if (timeElapsed < 1) {
listener.onReceiveConnections(mPreviousConnections);
return;
}
try {
JSONObject json = new JSONObject(s);
Map<String, JSONObject> jsonConnections = new HashMap<>();
jsonConnections.put(TOTAL_STATS, json.getJSONObject(TOTAL_STATS));
JSONArray extConnections = json.getJSONObject("connections").names();
if (extConnections != null) {
for (int i = 0; i < extConnections.length(); i++) {
String deviceId = extConnections.get(i).toString();
jsonConnections.put(deviceId, json.getJSONObject("connections").getJSONObject(deviceId));
}
}
Map<String, Connection> connections = new HashMap<>();
for (Map.Entry<String, JSONObject> jsonConnection : jsonConnections.entrySet()) {
String deviceId = jsonConnection.getKey();
Connection c = new Connection();
JSONObject conn = jsonConnection.getValue();
c.address = deviceId;
c.at = conn.getString("at");
c.inBytesTotal = conn.getLong("inBytesTotal");
c.outBytesTotal = conn.getLong("outBytesTotal");
c.address = conn.getString("address");
c.clientVersion = conn.getString("clientVersion");
c.completion = getDeviceCompletion(deviceId);
c.connected = conn.getBoolean("connected");
Connection prev = (mPreviousConnections.containsKey(deviceId))
? mPreviousConnections.get(deviceId)
: new Connection();
mPreviousConnectionTime = now;
c.inBits = Math.max(0, 8 *
(conn.getLong("inBytesTotal") - prev.inBytesTotal) / timeElapsed);
c.outBits = Math.max(0, 8 *
(conn.getLong("outBytesTotal") - prev.outBytesTotal) / timeElapsed);
connections.put(deviceId, c);
}
mPreviousConnections = connections;
listener.onReceiveConnections(mPreviousConnections);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse connections", e);
}
}
}.execute();
}
/**
* Calculates completion percentage for the given device using {@link #mCachedModelInfo}.
*/
private int getDeviceCompletion(String deviceId) {
int folderCount = 0;
float percentageSum = 0;
// Syncthing UI limits pending deletes to 95% completion of a device
int maxPercentage = 100;
for (Map.Entry<String, Model> modelInfo : mCachedModelInfo.entrySet()) {
boolean isShared = false;
outerloop:
for (Folder r : getFolders()) {
for (String n : r.deviceIds) {
if (n.equals(deviceId)) {
isShared = true;
break outerloop;
}
}
}
if (isShared) {
long global = modelInfo.getValue().globalBytes;
long local = modelInfo.getValue().inSyncBytes;
if (modelInfo.getValue().needFiles == 0 && modelInfo.getValue().needDeletes > 0)
maxPercentage = 95;
percentageSum += (global != 0)
? (local * 100f) / global
: 100f;
folderCount++;
}
}
return (folderCount != 0)
? Math.min(Math.round(percentageSum / folderCount), maxPercentage)
: 100;
}
/**
* Listener for {@link #getModel}.
*/
public interface OnReceiveModelListener {
public void onReceiveModel(String folderId, Model model);
}
/**
* Listener for {@link #getEvents}.
*/
public interface OnReceiveEventListener {
/**
* Called for each event.
*
* Events with a "folder" field in the data have an extra "folderpath" element added.
* @param eventType Name of the event. (See Syncthing documentation)
* @param data Contains the data fields of the event.
*/
void onEvent(String eventType, JSONObject data) throws JSONException;
/**
* Called after all available events have been processed.
* @param lastId The id of the last event processed. Should be used as a starting point for
* the next round of event processing.
*/
void onDone(long lastId);
}
/**
* Returns status information about the folder with the given id.
*/
public void getModel(final String folderId, final OnReceiveModelListener listener) {
new GetTask(mUrl, GetTask.URI_MODEL, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
if (s == null)
return;
try {
JSONObject json = new JSONObject(s);
Model m = new Model();
m.globalBytes = json.getLong("globalBytes");
m.globalDeleted = json.getLong("globalDeleted");
m.globalFiles = json.getLong("globalFiles");
m.localBytes = json.getLong("localBytes");
m.localDeleted = json.getLong("localDeleted");
m.localFiles = json.getLong("localFiles");
m.inSyncBytes = json.getLong("inSyncBytes");
m.inSyncFiles = json.getLong("inSyncFiles");
m.needBytes = json.getLong("needBytes");
m.needFiles = json.getLong("needFiles");
m.needDeletes = json.getLong("needDeletes");
m.state = json.getString("state");
m.invalid = json.optString("invalid");
mCachedModelInfo.put(folderId, m);
listener.onReceiveModel(folderId, m);
} catch (JSONException e) {
Log.w(TAG, "Failed to read folder info", e);
}
}
}.execute("folder", folderId);
}
/**
* Refreshes the lookup table to convert folder names to paths for events.
*/
private String getPathForFolder(String folderName) {
synchronized(mCacheFolderPathLookup) {
if (!mCacheFolderPathLookup.containsKey(folderName)) {
mCacheFolderPathLookup.clear();
for (Folder folder : getFolders()) {
mCacheFolderPathLookup.put(folder.id, folder.path);
}
}
return mCacheFolderPathLookup.get(folderName);
}
}
private void clearFolderCache() {
synchronized(mCacheFolderPathLookup) {
mCacheFolderPathLookup.clear();
}
}
/**
* Retrieves the events that have accumulated since the given event id.
*
* The OnReceiveEventListeners onEvent method is called for each event.
*/
public final void getEvents(final long sinceId, final long limit, final OnReceiveEventListener listener) {
new GetTask(mUrl, GetTask.URI_EVENTS, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
if (s == null)
return;
try {
JSONArray jsonEvents = new JSONArray(s);
long lastId = 0;
for (int i = 0; i < jsonEvents.length(); i++) {
JSONObject json = jsonEvents.getJSONObject(i);
String type = json.getString("type");
long id = json.getLong("id");
if (lastId < id)
lastId = id;
JSONObject data = json.optJSONObject("data");
// Add folder path to data.
if (data != null && data.has("folder")) {
String folder = data.getString("folder");
String folderPath = getPathForFolder(folder);
data.put("folderpath", folderPath);
}
listener.onEvent(type, data);
}
listener.onDone(lastId);
}
catch (JSONException e) {
Log.w(TAG, "Failed to read events", e);
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
"since", String.valueOf(sinceId), "limit", String.valueOf(limit));
}
/**
* Updates or creates the given device, depending on whether it already exists.
*
* @param device Settings of the device to edit. To create a device, pass a non-existant device ID.
* @param listener for the normalized device ID (may be null).
*/
public void editDevice(@NonNull final Device device, final Activity activity,
final OnDeviceIdNormalizedListener listener) {
normalizeDeviceId(device.deviceID,
(normalizedId, error) -> {
if (listener != null) listener.onDeviceIdNormalized(normalizedId, error);
if (normalizedId == null)
return;
device.deviceID = normalizedId;
// If the device already exists, just update it.
boolean create = true;
for (Device n : getDevices(true)) {
if (n.deviceID.equals(device.deviceID)) {
create = false;
}
}
try {
JSONArray devices = mConfig.getJSONArray("devices");
JSONObject n = null;
if (create) {
n = new JSONObject();
devices.put(n);
} else {
for (int i = 0; i < devices.length(); i++) {
JSONObject json = devices.getJSONObject(i);
if (device.deviceID.equals(json.getString("deviceID"))) {
n = devices.getJSONObject(i);
break;
}
}
}
n.put("deviceID", device.deviceID);
n.put("name", device.name);
n.put("addresses", new JSONArray(device.addresses));
n.put("compression", device.compression);
n.put("introducer", device.introducer);
requireRestart(activity);
} catch (JSONException e) {
Log.w(TAG, "Failed to read devices", e);
}
}
);
}
/**
* Deletes the given device from syncthing.
*/
public boolean deleteDevice(Device device, Activity activity) {
try {
JSONArray devices = mConfig.getJSONArray("devices");
for (int i = 0; i < devices.length(); i++) {
JSONObject json = devices.getJSONObject(i);
if (device.deviceID.equals(json.getString("deviceID"))) {
mConfig.remove("devices");
mConfig.put("devices", delete(devices, devices.getJSONObject(i)));
break;
}
}
requireRestart(activity);
} catch (JSONException e) {
Log.w(TAG, "Failed to edit folder", e);
return false;
}
return true;
}
/**
* Updates or creates the given device.
*/
public void editFolder(Folder folder, boolean create, Activity activity) {
try {
JSONArray folders = mConfig.getJSONArray("folders");
JSONObject r = null;
if (create) {
r = new JSONObject();
folders.put(r);
} else {
for (int i = 0; i < folders.length(); i++) {
JSONObject json = folders.getJSONObject(i);
if (folder.id.equals(json.getString("id"))) {
r = folders.getJSONObject(i);
break;
}
}
}
r.put("path", folder.path);
r.put("label", folder.label);
r.put("id", folder.id);
r.put("ignorePerms", true);
r.put("type", (folder.readOnly) ? "readonly" : "readwrite");
JSONArray devices = new JSONArray();
for (String n : folder.deviceIds) {
JSONObject element = new JSONObject();
element.put("deviceID", n);
devices.put(element);
}
r.put("devices", devices);
JSONObject versioning = new JSONObject();
versioning.put("type", folder.versioning.getType());
JSONObject params = new JSONObject();
versioning.put("params", params);
for (String key : folder.versioning.getParams().keySet()) {
params.put(key, folder.versioning.getParams().get(key));
}
r.put("rescanIntervalS", folder.rescanIntervalS);
r.put("versioning", versioning);
requireRestart(activity);
} catch (JSONException e) {
Log.w(TAG, "Failed to edit folder " + folder.id + " at " + folder.path, e);
return;
}
clearFolderCache();
}
/**
* Deletes the given folder from syncthing.
*/
public boolean deleteFolder(Folder folder, Activity activity) {
try {
JSONArray folders = mConfig.getJSONArray("folders");
for (int i = 0; i < folders.length(); i++) {
JSONObject json = folders.getJSONObject(i);
if (folder.id.equals(json.getString("id"))) {
mConfig.remove("folders");
mConfig.put("folders", delete(folders, folders.getJSONObject(i)));
break;
}
}
requireRestart(activity);
} catch (JSONException e) {
Log.w(TAG, "Failed to edit folder", e);
return false;
}
clearFolderCache();
return true;
}
/**
* Replacement for {@link org.json.JSONArray#remove(int)}, which is not available on older APIs.
*/
private JSONArray delete(JSONArray array, JSONObject delete) throws JSONException {
JSONArray newArray = new JSONArray();
for (int i = 0; i < array.length(); i++) {
if (!array.getJSONObject(i).equals(delete)) {
newArray.put(array.get(i));
}
}
return newArray;
}
/**
* Result listener for {@link #normalizeDeviceId(String, OnDeviceIdNormalizedListener)}.
*/
public interface OnDeviceIdNormalizedListener {
/**
* On any call, exactly one parameter will be null.
*
* @param normalizedId The normalized device ID, or null on error.
* @param error An error message, or null on success.
*/
public void onDeviceIdNormalized(String normalizedId, String error);
}
/**
* Normalizes a given device ID.
*/
public void normalizeDeviceId(final String id, final OnDeviceIdNormalizedListener listener) {
new GetTask(mUrl, GetTask.URI_DEVICEID, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
super.onPostExecute(s);
if (s == null)
return;
String normalized = null;
String error = null;
try {
JSONObject json = new JSONObject(s);
normalized = json.optString("id", null);
error = json.optString("error", null);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse normalized device ID JSON", e);
}
listener.onDeviceIdNormalized(normalized, error);
}
}.execute("id", id);
}
/**
* Shares the given device ID via Intent. Must be called from an Activity.
*/
public static void shareDeviceId(Context context, String id) {
Intent shareIntent = new Intent();
shareIntent.setAction(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, id);
context.startActivity(Intent.createChooser(
shareIntent, context.getString(R.string.send_device_id_to)));
}
/**
* Copies the given device ID to the clipboard (and shows a Toast telling about it).
*
* @param id The device ID to copy.
*/
public void copyDeviceId(String id) {
ClipboardManager clipboard = (ClipboardManager)
mContext.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(mContext.getString(R.string.device_id), id);
clipboard.setPrimaryClip(clip);
Toast.makeText(mContext, R.string.device_id_copied_to_clipboard, Toast.LENGTH_SHORT)
.show();
}
/**
* Force a rescan of the given subdirectory in folder.
*/
@Override
public void onFolderFileChange(String folderId, String relativePath) {
new PostTask(mUrl, PostTask.URI_SCAN, mHttpsCertPath, mApiKey).execute(folderId, relativePath);
}
/**
* Returns the object representing the local device.
*/
public Device getLocalDevice() {
for (Device d : getDevices(true)) {
if (d.deviceID.equals(mLocalDeviceId)) {
return d;
}
}
return new Device();
}
/**
* Returns value of usage reporting preference.
*/
public int getUsageReportAccepted() {
try {
int value = mConfig.getJSONObject(TYPE_OPTIONS).getInt("urAccepted");
if (value > USAGE_REPORTING_ACCEPTED)
throw new RuntimeException("Inalid usage reporting value");
if (!USAGE_REPORTING_DECIDED.contains(value))
value = USAGE_REPORTING_UNDECIDED;
return value;
} catch (JSONException e) {
Log.w(TAG, "Failed to read usage report value", e);
return USAGE_REPORTING_DENIED;
}
}
/**
* Sets new value for usage reporting preference.
*/
public void setUsageReportAccepted(int value, Activity activity) {
if (BuildConfig.DEBUG && !USAGE_REPORTING_DECIDED.contains(value))
throw new IllegalArgumentException("Invalid value for usage report");
try {
mConfig.getJSONObject(TYPE_OPTIONS).put("urAccepted", value);
} catch (JSONException e) {
Log.w(TAG, "Failed to set usage report value", e);
}
requireRestart(activity);
}
/**
* Callback for {@link #getUsageReport}.
*/
public interface OnReceiveUsageReportListener {
public void onReceiveUsageReport(String report);
}
/**
* Returns prettyfied usage report.
*/
public void getUsageReport(final OnReceiveUsageReportListener listener) {
new GetTask(mUrl, GetTask.URI_REPORT, mHttpsCertPath, mApiKey) {
@Override
protected void onPostExecute(String s) {
try {
if (s == null)
return;
listener.onReceiveUsageReport(new JSONObject(s).toString(4));
} catch (JSONException e) {
throw new RuntimeException("Failed to prettify usage report", e);
}
}
}.execute();
}
/**
* Sets {@link #mRestartPostponed} to true.
*/
public void setRestartPostponed() {
mRestartPostponed = true;
}
/**
* Returns the device name, or the first characters of the ID if the name is empty.
*/
public static String getDeviceDisplayName(Device device) {
return (TextUtils.isEmpty(device.name))
? device.deviceID.substring(0, 7)
: device.name;
}
}