package com.nutomic.syncthingandroid.service; import android.app.Activity; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; import android.util.Log; import com.google.common.base.Objects; import com.google.common.base.Optional; import com.google.common.collect.ImmutableMap; import com.google.common.reflect.TypeToken; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.nutomic.syncthingandroid.BuildConfig; import com.nutomic.syncthingandroid.activities.RestartActivity; import com.nutomic.syncthingandroid.http.GetRequest; import com.nutomic.syncthingandroid.http.PostConfigRequest; import com.nutomic.syncthingandroid.http.PostScanRequest; import com.nutomic.syncthingandroid.model.Config; import com.nutomic.syncthingandroid.model.Connections; import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.model.Event; import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.model.Model; import com.nutomic.syncthingandroid.model.Options; import com.nutomic.syncthingandroid.model.SystemInfo; import com.nutomic.syncthingandroid.model.SystemVersion; import com.nutomic.syncthingandroid.util.FolderObserver; import java.lang.reflect.Type; import java.net.URL; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; 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"; public interface OnConfigChangedListener { void onConfigChanged(); } public interface OnResultListener1<T> { public void onResult(T t); } public interface OnResultListener2<T, R> { public void onResult(T t, R r); } private final Context mContext; private final URL mUrl; private final String mApiKey; private final String mHttpsCertPath; private String mVersion; private Config mConfig; private String mLocalDeviceId; private boolean mRestartPostponed = false; /** * Stores the result of the last successful request to {@link GetRequest#URI_CONNECTIONS}, * or an empty Map. */ private Optional<Connections> mPreviousConnections = Optional.absent(); /** * Stores the timestamp of the last successful request to {@link GetRequest#URI_CONNECTIONS}. */ private long mPreviousConnectionTime = 0; /** * Stores the latest result of {@link #getModel} for each folder, for calculating device * percentage in {@link #getConnections}. */ private final HashMap<String, Model> mCachedModelInfo = 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 GetRequest(mContext, mUrl, GetRequest.URI_VERSION, mHttpsCertPath, mApiKey, null, result -> { JsonObject json = new JsonParser().parse(result).getAsJsonObject(); mVersion = json.get("version").getAsString(); Log.i(TAG, "Syncthing version is " + mVersion); tryIsAvailable(); }); new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mHttpsCertPath, mApiKey, null, result -> { mConfig = new Gson().fromJson(result, Config.class); tryIsAvailable(); }); 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(); } } /** * Either shows a restart dialog, or only updates the config, depending on * {@link #mRestartPostponed}. */ public void showRestartDialog(Activity activity) { if (mRestartPostponed) { sendConfig(); } else { activity.startActivity(new Intent(mContext, RestartActivity.class)); } mOnConfigChangedListener.onConfigChanged(); } /** * Sends current config to Syncthing. */ private void sendConfig() { new PostConfigRequest(mContext, mUrl, mHttpsCertPath, mApiKey, new Gson().toJson(mConfig), null); } /** * Sends current config and restarts Syncthing. */ public void restart() { new PostConfigRequest(mContext, mUrl, mHttpsCertPath, mApiKey, new Gson().toJson(mConfig), result -> { Intent intent = new Intent(mContext, SyncthingService.class) .setAction(SyncthingService.ACTION_RESTART); mContext.startService(intent); }); } /** * Stops syncthing and cancels notification. */ public void shutdown() { NotificationManager nm = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); nm.cancel(RestartActivity.NOTIFICATION_RESTART); mRestartPostponed = false; } /** * Returns the version name, or a (text) error message on failure. */ public String getVersion() { return mVersion; } public List<Folder> getFolders() { return deepCopy(mConfig.folders, new TypeToken<List<Folder>>(){}.getType()); } public void addFolder(Folder folder) { mConfig.folders.add(folder); sendConfig(); } public void editFolder(Folder newFolder) { removeFolderInternal(newFolder.id); addFolder(newFolder); } public void removeFolder(String id) { removeFolderInternal(id); sendConfig(); } private void removeFolderInternal(String id) { Iterator<Folder> it = mConfig.folders.iterator(); while (it.hasNext()) { Folder f = it.next(); if (f.id.equals(id)) { it.remove(); } } } /** * 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) { List<Device> devices = deepCopy(mConfig.devices, new TypeToken<List<Device>>(){}.getType()); Iterator<Device> it = devices.iterator(); while (it.hasNext()) { Device device = it.next(); boolean isLocalDevice = Objects.equal(mLocalDeviceId, device.deviceID); if (!includeLocal && isLocalDevice) it.remove(); } return devices; } public Device getLocalDevice() { for (Device d : getDevices(true)) { if (d.deviceID.equals(mLocalDeviceId)) { return deepCopy(d, Device.class); } } throw new RuntimeException(); } public void addDevice(Device device, OnResultListener1<String> errorListener) { normalizeDeviceId(device.deviceID, normalizedId -> { mConfig.devices.add(device); sendConfig(); }, errorListener); } public void editDevice(Device newDevice) { removeDeviceInternal(newDevice.deviceID); mConfig.devices.add(newDevice); sendConfig(); } public void removeDevice(String deviceId) { removeDeviceInternal(deviceId); sendConfig(); } private void removeDeviceInternal(String deviceId) { Iterator<Device> it = mConfig.devices.iterator(); while (it.hasNext()) { Device d = it.next(); if (d.deviceID.equals(deviceId)) { it.remove(); } } } public Options getOptions() { return deepCopy(mConfig.options, Options.class); } public Config.Gui getGui() { return deepCopy(mConfig.gui, Config.Gui.class); } public void editSettings(Config.Gui newGui, Options newOptions, Activity activity) { mConfig.gui = newGui; mConfig.options = newOptions; showRestartDialog(activity); } /** * Returns a deep copy of object. * * This method uses Gson and only works with objects that can be converted with Gson. */ public <T> T deepCopy(T object, Type type) { Gson gson = new Gson(); return gson.fromJson(gson.toJson(object, type), type); } /** * Requests and parses information about current system status and resource usage. */ public void getSystemInfo(OnResultListener1<SystemInfo> listener) { new GetRequest(mContext, mUrl, GetRequest.URI_SYSTEM, mHttpsCertPath, mApiKey, null, result -> { listener.onResult(new Gson().fromJson(result, SystemInfo.class)); }); } public boolean isConfigLoaded() { return mConfig != null; } /** * Requests and parses system version information. */ public void getSystemVersion(OnResultListener1<SystemVersion> listener) { new GetRequest(mContext, mUrl, GetRequest.URI_VERSION, mHttpsCertPath, mApiKey, null, result -> { SystemVersion systemVersion = new Gson().fromJson(result, SystemVersion.class); listener.onResult(systemVersion); }); } /** * Returns connection info for the local device and all connected devices. */ public void getConnections(final OnResultListener1<Connections> listener) { new GetRequest(mContext, mUrl, GetRequest.URI_CONNECTIONS, mHttpsCertPath, mApiKey, null, result -> { Long now = System.currentTimeMillis(); Long msElapsed = now - mPreviousConnectionTime; if (msElapsed < SyncthingService.GUI_UPDATE_INTERVAL) { listener.onResult(deepCopy(mPreviousConnections.get(), Connections.class)); return; } mPreviousConnectionTime = now; Connections connections = new Gson().fromJson(result, Connections.class); for (Map.Entry<String, Connections.Connection> e : connections.connections.entrySet()) { e.getValue().completion = getDeviceCompletion(e.getKey()); Connections.Connection prev = (mPreviousConnections.isPresent() && mPreviousConnections.get().connections.containsKey(e.getKey())) ? mPreviousConnections.get().connections.get(e.getKey()) : new Connections.Connection(); e.getValue().setTransferRate(prev, msElapsed); } Connections.Connection prev = mPreviousConnections.transform(c -> c.total).or(new Connections.Connection()); connections.total.setTransferRate(prev, msElapsed); mPreviousConnections = Optional.of(connections); listener.onResult(deepCopy(connections, Connections.class)); }); } /** * 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; for (Folder r : getFolders()) { if (r.getDevice(deviceId) != null) { isShared = true; break; } } 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; } /** * Returns status information about the folder with the given id. */ public void getModel(final String folderId, final OnResultListener2<String, Model> listener) { new GetRequest(mContext, mUrl, GetRequest.URI_MODEL, mHttpsCertPath, mApiKey, ImmutableMap.of("folder", folderId), result -> { Model m = new Gson().fromJson(result, Model.class); mCachedModelInfo.put(folderId, m); listener.onResult(folderId, m); }); } /** * Listener for {@link #getEvents}. */ public interface OnReceiveEventListener { /** * Called for each event. */ void onEvent(Event event); /** * 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); } /** * 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) { Map<String, String> params = ImmutableMap.of("since", String.valueOf(sinceId), "limit", String.valueOf(limit)); new GetRequest(mContext, mUrl, GetRequest.URI_EVENTS, mHttpsCertPath, mApiKey, params, result -> { JsonArray jsonEvents = new JsonParser().parse(result).getAsJsonArray(); long lastId = 0; for (int i = 0; i < jsonEvents.size(); i++) { JsonElement json = jsonEvents.get(i); Event event = new Gson().fromJson(json, Event.class); if (lastId < event.id) lastId = event.id; listener.onEvent(event); } listener.onDone(lastId); }); } /** * Normalizes a given device ID. */ public void normalizeDeviceId(String id, OnResultListener1<String> listener, OnResultListener1<String> errorListener) { new GetRequest(mContext, mUrl, GetRequest.URI_DEVICEID, mHttpsCertPath, mApiKey, ImmutableMap.of("id", id), result -> { JsonObject json = new JsonParser().parse(result).getAsJsonObject(); JsonElement normalizedId = json.get("id"); JsonElement error = json.get("error"); if (normalizedId != null) listener.onResult(normalizedId.getAsString()); if (error != null) errorListener.onResult(error.getAsString()); }); } /** * Force a rescan of the given subdirectory in folder. */ @Override public void onFolderFileChange(String folderId, String relativePath) { new PostScanRequest(mContext, mUrl, mHttpsCertPath, mApiKey, folderId, relativePath); } /** * Returns prettyfied usage report. */ public void getUsageReport(final OnResultListener1<String> listener) { new GetRequest(mContext, mUrl, GetRequest.URI_REPORT, mHttpsCertPath, mApiKey, null, result -> { JsonElement json = new JsonParser().parse(result); Gson gson = new GsonBuilder().setPrettyPrinting().create(); listener.onResult(gson.toJson(json)); }); } public void setRestartPostponed() { mRestartPostponed = true; } }