/* * Copyright (c) 2015 OpenSilk Productions LLC * * 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/>. */ package syncthing.api; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import javax.inject.Inject; import javax.inject.Named; import rx.Observable; import rx.Scheduler; import rx.Subscription; import rx.android.schedulers.AndroidSchedulers; import rx.functions.Action0; import rx.functions.Action1; import rx.schedulers.Schedulers; import rx.subjects.BehaviorSubject; import rx.subjects.SerializedSubject; import syncthing.api.model.Completion; import syncthing.api.model.Config; import syncthing.api.model.ConfigStats; import syncthing.api.model.ConnectionInfo; import syncthing.api.model.Connections; import syncthing.api.model.DeviceConfig; import syncthing.api.model.DeviceStats; import syncthing.api.model.DeviceStatsMap; import syncthing.api.model.FolderConfig; import syncthing.api.model.FolderDeviceConfig; import syncthing.api.model.FolderStats; import syncthing.api.model.FolderStatsMap; import syncthing.api.model.GUIConfig; import syncthing.api.model.Ignores; import syncthing.api.model.Model; import syncthing.api.model.ModelState; import syncthing.api.model.OptionsConfig; import syncthing.api.model.SystemErrors; import syncthing.api.model.SystemInfo; import syncthing.api.model.SystemMessage; import syncthing.api.model.Version; import syncthing.api.model.event.ConfigSaved; import syncthing.api.model.event.DevicePaused; import syncthing.api.model.event.DeviceRejected; import syncthing.api.model.event.DeviceResumed; import syncthing.api.model.event.Event; import syncthing.api.model.event.FolderCompletion; import syncthing.api.model.event.FolderErrors; import syncthing.api.model.event.FolderRejected; import syncthing.api.model.event.FolderScanProgress; import syncthing.api.model.event.FolderSummary; import syncthing.api.model.event.StateChanged; import timber.log.Timber; /** * Created by drew on 3/4/15. */ @SessionScope public class SessionController implements EventMonitor.EventListener { public enum Change { ONLINE, OFFLINE, FAILURE, COMPLETION, //FolderCompletion.Data or none FOLDER_STATS, DEVICE_STATS, CONNECTIONS_UPDATE, CONNECTIONS_CHANGE, CONFIG_UPDATE, SYSTEM, NOTICE, //EventMonitor NEED_LOGIN, DEVICE_DISCOVERED, DEVICE_CONNECTED, DEVICE_DISCONNECTED, DEVICE_PAUSED, //DevicePaused.Data DEVICE_REJECTED, DEVICE_RESUMED, //DeviceResumed.Data LOCAL_INDEX_UPDATED, REMOTE_INDEX_UPDATED, ITEM_STARTED, ITEM_FINISHED, //ItemFinished.Data STATE_CHANGED, //StateChanged.Data FOLDER_REJECTED, CONFIG_SAVED, DOWNLOAD_PROGRESS, FOLDER_COMPLETION, FOLDER_SUMMARY, //FolderSummary.Data FOLDER_ERRORS, //FolderErrors.Data FOLDER_SCAN_PROGRESS, //FolderScanProgress.Data } public static class ChangeEvent { public static final Object NONE = new Object(); public final Change change; //Event specific data public final Object data; public ChangeEvent(Change change, Object data) { this.change = change; this.data = data == null ? NONE : data; } } final AtomicReference<SystemInfo> systemInfo = new AtomicReference<>(); final AtomicReference<String> myId = new AtomicReference<>(); final AtomicReference<Config> config = new AtomicReference<>(); final AtomicBoolean configInSync = new AtomicBoolean(); //synchronize on self final Connections connections = new Connections(); //synchronize on self final DeviceStatsMap deviceStats = new DeviceStatsMap(); //synchronize on self final FolderStatsMap folderStats = new FolderStatsMap(); final AtomicReference<Version> version = new AtomicReference<>(); //synchronize on self final Map<String, Model> models = new HashMap<>(10); //synchronize on self final Map<String, FolderConfig> folders = new LinkedHashMap<>(10); //we pre sort this //synchronize on self final Map<String, DeviceConfig> devices = new LinkedHashMap<>(10); //we pre sort this //synchronize on self TODO a map of a map? really??? final Map<String, Map<String, Float>> completion = new HashMap<>(10); //synchronize on self final Map<String, DeviceRejected> deviceRejections = new LinkedHashMap<>(); //want displayed in order received //synchronize on self final Map<String, FolderRejected> folderRejections = new LinkedHashMap<>(); //want displayed in order received final AtomicReference<SystemErrors> errorsList = new AtomicReference<>(); //synchronize on self final WeakHashMap<String, Subscription> activeSubscriptions = new WeakHashMap<>(); //synchronize on self final Map<String, FolderScanProgress.Data> folderScanProgress = new HashMap<>(); //synchronize on self final Map<String, List<FolderErrors.Error>> folderErrors = new HashMap<>(); //Following synchronized by lock private final Object lock = new Object(); boolean online; boolean restarting; boolean running; static final String suspendSubscriptionKey = "suspendSubscription"; final Scheduler subscribeOn; final SyncthingApi restApi; final EventMonitor eventMonitor; final SerializedSubject<ChangeEvent, ChangeEvent> changeBus = BehaviorSubject.<ChangeEvent>create().toSerialized(); @Inject public SessionController(SyncthingApi restApi, @Named("longpoll") SyncthingApi longpollRestApi) { Timber.i("new SessionController"); this.subscribeOn = Schedulers.io();//TODO allow configure this.restApi = SynchingApiWrapper.wrap(restApi, subscribeOn); this.eventMonitor = new EventMonitor(longpollRestApi, this); } public void init() { synchronized (lock) { Subscription s = removeSubscription(suspendSubscriptionKey); if (s != null) { s.unsubscribe(); } if (!eventMonitor.isRunning()) { eventMonitor.start(); } running = true; } } public void suspend() { synchronized (lock) { if (running) { Subscription s = removeSubscription(suspendSubscriptionKey); if (s != null) { s.unsubscribe(); } // add delay to allow for configuration changes s = Observable.timer(30, TimeUnit.SECONDS, subscribeOn) .subscribe(ii -> { synchronized (lock) { eventMonitor.stop(); online = false; removeSubscription(suspendSubscriptionKey); } }); addSubscription(suspendSubscriptionKey, s); running = false; } } } /*package*/ void kill() { final Scheduler.Worker worker = subscribeOn.createWorker(); worker.schedule(() -> { synchronized (lock) { eventMonitor.stop(); running = false; online = false; } unsubscribeActiveSubscriptions(); worker.unsubscribe(); }); } public boolean isOnline() { synchronized (lock) { return online; } } public boolean isRestarting() { synchronized (lock) { return restarting; } } public boolean isRunning() { synchronized (lock) { return running; } } public void handleEvent(Event e) { if (updateState(true)) { Timber.d("Eating event %s", e.type); } Timber.d("New event %s", e.type); switch (e.type) { case STARTUP_COMPLETE: { break; } case STATE_CHANGED: { StateChanged st = (StateChanged) e; boolean incremental = false; synchronized (models) { if (models.containsKey(st.data.folder)) { models.get(st.data.folder).state = st.data.to; incremental = true; } } if (st.data.to == ModelState.SCANNING) { //remove any stale scan progress synchronized (folderScanProgress) { folderScanProgress.remove(st.data.folder); } } if (incremental) { postChange(Change.STATE_CHANGED, st.data); } else { refreshFolder(st.data.folder); } break; } case LOCAL_INDEX_UPDATED: { onLocalIndexUpdated(e); break; } case REMOTE_INDEX_UPDATED: { //pass break; } case DEVICE_CONNECTED: { refreshConnections(); refreshDeviceStats(); break; } case DEVICE_DISCONNECTED: { refreshConnections(); refreshDeviceStats(); break; } case DEVICE_DISCOVERED: { break; } case DEVICE_PAUSED: { String id = ((DevicePaused.Data)e.data).device; synchronized (connections) { if (connections.connections.containsKey(id)) { connections.connections.get(id).paused = true; } } postChange(Change.DEVICE_PAUSED, e.data); break; } case DEVICE_RESUMED: { String id = ((DeviceResumed.Data)e.data).device; synchronized (connections) { if (connections.connections.containsKey(id)) { connections.connections.get(id).paused = false; } } postChange(Change.DEVICE_RESUMED, e.data); break; } case DEVICE_REJECTED: { DeviceRejected dr = (DeviceRejected) e; if (getDevice(dr.data.device) == null) { synchronized (deviceRejections) { deviceRejections.put(dr.data.device, dr); } postChange(Change.DEVICE_REJECTED); } else { Timber.w("Ignoring DEVICE_REJECTED for %s", dr.data.device); } break; } case FOLDER_REJECTED: { FolderRejected fr = (FolderRejected) e; if (getFolder(fr.data.folder) == null) { synchronized (folderRejections) { folderRejections.put(fr.data.folder + "★" + fr.data.device, fr); } postChange(Change.FOLDER_REJECTED); } else { Timber.w("Ignoring FOLDER_REJECTED for %s from %s", fr.data.folder, fr.data.device); } break; } case CONFIG_SAVED: { ConfigSaved cs = (ConfigSaved) e; updateConfig(cs.data); postChange(Change.CONFIG_UPDATE); refreshConfigStats(); break; } case DOWNLOAD_PROGRESS: { break; } case FOLDER_SUMMARY: { FolderSummary fs = (FolderSummary) e; updateModel(fs.data.folder, fs.data.summary); postChange(Change.FOLDER_SUMMARY, fs.data); break; } case FOLDER_COMPLETION: { FolderCompletion fc = (FolderCompletion) e; updateCompletion(fc.data.device, fc.data.folder, new Completion(fc.data.completion)); postChange(Change.COMPLETION, fc.data); break; } case FOLDER_ERRORS: { FolderErrors fe = (FolderErrors) e; updateFolderErrors(fe.data.folder, fe.data.errors); postChange(Change.FOLDER_ERRORS, fe.data); break; } case FOLDER_SCAN_PROGRESS: { FolderScanProgress.Data d = (FolderScanProgress.Data) e.data; synchronized (folderScanProgress) { folderScanProgress.put(d.folder, d); } postChange(Change.FOLDER_SCAN_PROGRESS, d); break; } case ITEM_FINISHED: { postChange(Change.ITEM_FINISHED, e.data); break; } case ITEM_STARTED: { break; } case RELAY_STATE_CHANGED: { refreshSystem(); break; } case PING: { refreshSystem(); refreshConnections(true); refreshErrors(); refreshConfigStats(); break; } default: { break; } } } @Override public void onError(EventMonitor.Error e) { Timber.w("onError %s", e.toString()); switch (e) { case UNAUTHORIZED: updateState(false); postChange(Change.NEED_LOGIN); break; case DISCONNECTED: updateState(false); break; case STOPPING: synchronized (lock) { running = false; } updateState(false); postChange(Change.FAILURE); break; } } static final String updateStateKey = "updateState"; boolean updateState(boolean online) { synchronized (lock) { //No state change dont eat event; if (this.online == online) return false; //New event came in while we are initializing, eat it if (online && hasActiveSubscription(updateStateKey)) return true; if (online) { this.restarting = false; //Our online state depends on all these items //so we merge them together so we can defer //posting the ONLINE status until we have set all the values Subscription s = Observable.merge( retryOnce(restApi.system()).map(r -> Pair.of(1, r)), retryOnce(restApi.config()).map(r -> Pair.of(2, r)), retryOnce(restApi.configStatus()).map(r -> Pair.of(3, r)), retryOnce(restApi.connections()).map(r -> Pair.of(4, r)), retryOnce(restApi.deviceStats()).map(r -> Pair.of(5, r)), retryOnce(restApi.folderStats()).map(r -> Pair.of(6, r)), retryOnce(restApi.version()).map(r -> Pair.of(7, r)) ).subscribe( (p) -> { switch (p.getLeft()) { case 1: updateSystemInfo((SystemInfo) p.getRight()); break; case 2: updateConfig((Config) p.getRight()); break; case 3: updateConfigStats((ConfigStats) p.getRight()); break; case 4: updateConnections((Connections) p.getRight()); break; case 5: setDeviceStats((DeviceStatsMap) p.getRight()); break; case 6: setFolderStats((FolderStatsMap) p.getRight()); break; case 7: setVersion((Version) p.getRight()); break; } }, (t) -> { synchronized (lock) { this.online = false; logException(t, updateStateKey); } //TODO this isnt really the right thing to do postChange(Change.FAILURE); }, () -> { synchronized (lock) { this.online = true; removeSubscription(updateStateKey); } postChange(Change.ONLINE); } ); addSubscription(updateStateKey, s); } else { this.online = false; removeSubscription(updateStateKey); postChange(Change.OFFLINE); } return true; } } void postChange(Change change) { postChange(change, null); } void postChange(Change change, Object data) { switch (change) { case OFFLINE: case NEED_LOGIN: case FAILURE: sendChangeEvent(new ChangeEvent(change, data)); return; default: if (isOnline()) { sendChangeEvent(new ChangeEvent(change, data)); } else { Timber.w("Dropping change %s while offline", change.toString()); } } } void sendChangeEvent(ChangeEvent event) { changeBus.onNext(event); } static final String refreshSystemKey = "refreshSystem"; public void refreshSystem() { if (hasActiveSubscription(refreshSystemKey)) return; Subscription s = restApi.system() .subscribe( this::updateSystemInfo, (t) -> logException(t, refreshSystemKey), () -> { postChange(Change.SYSTEM); removeSubscription(refreshSystemKey); } ); addSubscription(refreshSystemKey, s); } static final String refreshConfigKey = "refreshConfig"; public void refreshConfig() { if (hasActiveSubscription(refreshConfigKey)) return; Subscription s = restApi.config() .subscribe( this::updateConfig, (t) -> logException(t, refreshConfigKey), () -> { postChange(Change.CONFIG_UPDATE); removeSubscription(refreshConfigKey); } ); addSubscription(refreshConfigKey, s); } static final String refreshConfigStatsKey = "refreshConfigStats"; public void refreshConfigStats() { if (hasActiveSubscription(refreshConfigStatsKey)) return; Subscription s = restApi.configStatus() .subscribe( this::updateConfigStats, (t) -> logException(t, refreshConfigStatsKey), () -> removeSubscription(refreshConfigStatsKey) ); addSubscription(refreshConfigStatsKey, s); } public void refreshConnections() { refreshConnections(false); } static final String refreshConnectionsKey = "refreshConnections"; public void refreshConnections(boolean update) { if (hasActiveSubscription(refreshConnectionsKey)) return; Subscription s = restApi.connections() .subscribe( this::updateConnections, (t) -> logException(t, refreshConnectionsKey), () -> { postChange(update ? Change.CONNECTIONS_UPDATE : Change.CONNECTIONS_CHANGE); removeSubscription(refreshConnectionsKey); }); addSubscription(refreshConnectionsKey, s); } static final String refreshDeviceStatsKey = "refreshDeviceStats"; public void refreshDeviceStats() { if (hasActiveSubscription(refreshDeviceStatsKey)) return; Subscription s = restApi.deviceStats() .subscribe( this::setDeviceStats, (t) -> logException(t, refreshDeviceStatsKey), () -> { postChange(Change.DEVICE_STATS); removeSubscription(refreshDeviceStatsKey); } ); addSubscription(refreshDeviceStatsKey, s); } static final String refreshFolderStatsKey = "refreshFolderStats"; public void refreshFolderStats() { if (hasActiveSubscription(refreshFolderStatsKey)) return; Subscription s = restApi.folderStats() .subscribe( this::setFolderStats, (t) -> logException(t, refreshFolderStatsKey), () -> { postChange(Change.FOLDER_STATS); removeSubscription(refreshFolderStatsKey); } ); addSubscription(refreshFolderStatsKey, s); } public void refreshVersion() { Subscription s = restApi.version() .subscribe(this::setVersion, this::logException); } public void refreshFolder(String name) { final String key = "refreshFolder+"+name; if (hasActiveSubscription(key)) return; Subscription s = restApi.model(name) .subscribe( model -> { updateModel(name, model); postChange(Change.FOLDER_SUMMARY, new FolderSummary.Data(name, model)); }, (t) -> logException(t, key), () -> removeSubscription(key) ); addSubscription(key, s); } public void refreshFolders(Collection<String> names) { for (String name : names) { refreshFolder(name); } } public void refreshCompletion(String device, String folder) { Subscription s = restApi.completion(device, folder) .subscribe( (comp) -> { updateCompletion(device, folder, comp); }, this::logException, () -> postChange(Change.COMPLETION) ); } //Wants Device,Folder pair public void refreshCompletions(Collection<Pair<String, String>> refreshers) { if (refreshers.isEmpty()) { return; } //Same as refreshFolders but we need to keep track of folders and devices List<Observable<Triple<String, String, Completion>>> observables = new ArrayList<>(); for (Pair<String, String> refresh : refreshers) { observables.add(Observable.zip( Observable.just(refresh.getLeft()),//device Observable.just(refresh.getRight()),//folder restApi.completion(refresh.getLeft(), refresh.getRight()).first(), Triple::of) ); } Subscription s = Observable.merge(observables) .subscribe( (triple) -> updateCompletion(triple.getLeft(), triple.getMiddle(), triple.getRight()), this::logException, () -> postChange(Change.COMPLETION) ); } static final String localIndexUpdatedKey = "localIndexUpdated"; void onLocalIndexUpdated(Event e) { if (hasActiveSubscription(localIndexUpdatedKey)) return; Subscription s = Observable.timer(500, TimeUnit.MILLISECONDS, subscribeOn) .subscribe( ii -> refreshFolderStats(), (t) -> logException(t, localIndexUpdatedKey), () -> removeSubscription(localIndexUpdatedKey) ); addSubscription(localIndexUpdatedKey, s); } public SystemInfo getSystemInfo() { return systemInfo.get(); } public String getMyID() { return myId.get(); } void updateSystemInfo(SystemInfo systemInfo) { this.myId.set(systemInfo.myID); this.systemInfo.set(systemInfo); } public Config getConfig() { return config.get(); } void updateConfig(Config config) { Collections.sort(config.folders, (lhs, rhs) -> lhs.id.compareTo(rhs.id)); synchronized (folders) { folders.clear(); for (FolderConfig f : config.folders) { folders.put(f.id, f); } } Collections.sort(config.devices, (lhs, rhs) -> lhs.deviceID.compareTo(rhs.deviceID)); synchronized (devices) { devices.clear(); for (DeviceConfig d : config.devices) { devices.put(d.deviceID, d); } } int fold, fnew; synchronized (folderRejections) { fold = folderRejections.size(); //remove any stale rejections Iterator<Map.Entry<String, FolderRejected>> ii = folderRejections.entrySet().iterator(); while (ii.hasNext()) { //TODO this isnt right //we need to check the device as well //but not doing that now as the add folder screen cant //handle adding a device to a folder that already exists String[] split = StringUtils.split(ii.next().getKey(), '★'); if (split != null && split.length > 0) { if (getFolder(split[0]) != null) { ii.remove(); } } } fnew = folderRejections.size(); } int dold, dnew; synchronized (deviceRejections) { dold = deviceRejections.size(); //remove any stale rejections Iterator<Map.Entry<String, DeviceRejected>> ii = deviceRejections.entrySet().iterator(); while (ii.hasNext()) { if (getDevice(ii.next().getKey()) != null) { ii.remove(); } } dnew = deviceRejections.size(); } if (fold != fnew || dold != dnew) { postChange(Change.NOTICE); } this.config.set(config); } public boolean isConfigInSync() { return configInSync.get(); } void updateConfigStats(ConfigStats configStats) { boolean inSync = this.configInSync.getAndSet(configStats.configInSync); if (inSync != configStats.configInSync) { postChange(Change.NOTICE); refreshConfig(); } } public @Nullable ConnectionInfo getConnection(String id) { synchronized (connections) { return connections.connections.get(id); } } public ConnectionInfo getConnectionTotal() { synchronized (connections) { return connections.total; } } void updateConnections(Connections conns) { long now = System.currentTimeMillis(); synchronized (connections) { for (String key : conns.connections.keySet()) { ConnectionInfo newC = conns.connections.get(key); newC.deviceId = key; newC.lastUpdate = now; if (connections.connections.containsKey(key)) { ConnectionInfo oldC = connections.connections.get(key); long td = (now - oldC.lastUpdate) / 1000; if (td > 0) { newC.inbps = Math.max(0, (newC.inBytesTotal - oldC.inBytesTotal) / td); newC.outbps = Math.max(0, (newC.outBytesTotal - oldC.outBytesTotal) / td); } } //also update completion resetCompletionTotal(key); } connections.connections = conns.connections; connections.total = conns.total; } } public DeviceStats getDeviceStats(String id) { synchronized (deviceStats) { return deviceStats.get(id); } } void setDeviceStats(DeviceStatsMap deviceStats) { synchronized (this.deviceStats) { this.deviceStats.clear(); this.deviceStats.putAll(deviceStats); } } public FolderStats getFolderStats(String name) { synchronized (folderStats) { return folderStats.get(name); } } void setFolderStats(FolderStatsMap folderStats) { synchronized (this.folderStats) { this.folderStats.clear(); this.folderStats.putAll(folderStats); } } public Version getVersion() { return version.get(); } void setVersion(Version version) { this.version.set(version); } public @Nullable Model getModel(String folderName) { synchronized (models) { return models.get(folderName); } } void updateModel(String folderName, Model model) { Timber.d("updateModel(%s) m=%s", folderName, model); synchronized (models) { if (models.containsKey(folderName)) { models.remove(folderName); } models.put(folderName, model); } } public @Nullable FolderConfig getFolder(String name) { synchronized (folders) { return folders.get(name); } } public List<FolderConfig> getFolders() { synchronized (folders) { return new ArrayList<>(folders.values()); } } public @Nullable FolderScanProgress.Data getFolderScanProgress(String folder) { synchronized (folderScanProgress) { return folderScanProgress.get(folder); } } public List<DeviceConfig> getDevices() { synchronized (devices) { return new ArrayList<>(devices.values()); } } public @Nullable DeviceConfig getThisDevice() { synchronized (devices) { return devices.get(getMyID()); } } public @NonNull List<DeviceConfig> getRemoteDevices() { Map<String, DeviceConfig> devs; synchronized (devices) { devs = new HashMap<>(devices); } devs.remove(getMyID()); return new ArrayList<>(devs.values()); } public @Nullable DeviceConfig getDevice(String deviceId) { synchronized (devices) { return devices.get(deviceId); } } void updateCompletion(String device, String folder, Completion comp) { synchronized (completion) { if (!completion.containsKey(device)) { completion.put(device, new HashMap<String, Float>()); } completion.get(device).put(folder, comp.completion); float tot = 0; int cnt = 0; for (String key : completion.get(device).keySet()) { if ("_total".equals(key)) continue; tot += completion.get(device).get(key); cnt++; } completion.get(device).put("_total", tot / cnt); } } void resetCompletionTotal(String deviceId) { synchronized (completion) { if (!completion.containsKey(deviceId)) { completion.put(deviceId, new HashMap<String, Float>()); } completion.get(deviceId).put("_total", 100f); } } public int getCompletionTotal(String deviceId) { synchronized (completion) { if (completion.containsKey(deviceId)) { return Math.min(100, Math.round(completion.get(deviceId).get("_total"))); } else { return -1; } } } public @Nullable Map<String, Float> getCompletionStats(String deviceId) { synchronized (completion) { return Collections.unmodifiableMap(completion.get(deviceId)); } } public Set<Map.Entry<String, DeviceRejected>> getDeviceRejections() { synchronized (deviceRejections) { return new LinkedHashSet<>(deviceRejections.entrySet()); } } public void removeDeviceRejection(String key) { synchronized (deviceRejections) { deviceRejections.remove(key); } postChange(Change.DEVICE_REJECTED); } public Set<Map.Entry<String, FolderRejected>> getFolderRejections() { synchronized (folderRejections) { return new LinkedHashSet<>(folderRejections.entrySet()); } } public void removeFolderRejection(String key) { synchronized (folderRejections) { folderRejections.remove(key); } postChange(Change.FOLDER_REJECTED); } static final String refreshErrorsKey = "refreshErrors"; public void refreshErrors() { if (hasActiveSubscription(refreshErrorsKey)) return; Subscription s = restApi.errors() .subscribe( errorsList::set, (t) -> logException(t, refreshErrorsKey), () -> { postChange(Change.NOTICE); removeSubscription(refreshErrorsKey); } ); addSubscription(refreshErrorsKey, s); } static final String clearErrorsKey = "clearErrors"; public void clearErrors() { if (hasActiveSubscription(clearErrorsKey)) return; Subscription s = restApi.clearErrors().subscribe( ignoreOnNext(), (t) -> logException(t, clearErrorsKey), () -> { errorsList.set(null); removeSubscription(clearErrorsKey); postChange(Change.NOTICE); }); addSubscription(clearErrorsKey, s); } public @Nullable SystemMessage getLatestError() { List<SystemMessage> errors = errorsList.get() != null ? errorsList.get().errors : null; if (errors != null && errors.size() > 0) { return errors.get(errors.size() - 1); } else { return null; } } void updateFolderErrors(String folder, List<FolderErrors.Error> errors) { synchronized (folderErrors) { folderErrors.put(folder, errors); } } public @NonNull List<FolderErrors.Error> getFolderErrors(String folder) { synchronized (folderErrors) { List<FolderErrors.Error> errors = folderErrors.get(folder); if (errors != null) { return new ArrayList<>(errors); } else { return Collections.emptyList(); } } } public Subscription editFolder(FolderConfig folder, Action1<Throwable> onError, Action0 onComplete) { return restApi.config() .map((config) -> { if (config.folders.isEmpty()) { config.folders = Collections.singletonList(folder); } else { int index = config.folders.indexOf(folder); if (index < 0) { config.folders.add(folder); } else { config.folders.set(index, folder); } } return config; }) .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription deleteFolder(FolderConfig folder, Action1<Throwable> onError, Action0 onComplete) { return restApi.config() .map((config) -> { if (!config.folders.remove(folder)) { throw new IllegalArgumentException("Folder " + folder.id + " not found in config"); } config.folders.remove(folder); return config; }) .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription shareFolder(String name, String devId, Action1<Throwable> onError, Action0 onComplete) { return restApi.config().zipWith(restApi.deviceId(devId), (config, deviceId) -> { if (deviceId.id == null) { throw new NullPointerException(deviceId.error); } boolean folderUpdated = false; for (FolderConfig folder : config.folders) { if (StringUtils.equals(folder.id, name)) { if (folder.devices.isEmpty()) { folder.devices = new ArrayList<>(); } for (FolderDeviceConfig d : folder.devices) { if (StringUtils.equals(d.deviceID, deviceId.id)) { throw new IllegalArgumentException("Folder already shared with device " + d.deviceID); } } folder.devices.add(new FolderDeviceConfig(deviceId.id)); folderUpdated = true; break; } } if (!folderUpdated) { throw new IllegalArgumentException("Folder doesn't exist " + name); } return config; }) .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription editDevice(DeviceConfig device, Map<String, Boolean> folders, Action1<Throwable> onError, Action0 onComplete) { return restApi.deviceId(device.deviceID) // fetched the normalized id // then we update the device with the new id // and update the config .zipWith(restApi.config(), (deviceId, config) -> { if (deviceId.id == null) { throw new NullPointerException(deviceId.error); } Timber.d("editDevice() updating deviceId %s -> %s", device.deviceID, deviceId.id); device.deviceID = deviceId.id; if (config.devices.isEmpty()) { config.devices = Collections.singletonList(device); } else { int index = config.devices.indexOf(device); if (index < 0) { config.devices.add(device); } else { config.devices.set(index, device); } } if (folders != null && !folders.isEmpty()) { for (FolderConfig f : config.folders) { if (folders.containsKey(f.id)) { boolean wants = folders.get(f.id); if (f.devices == null || f.devices.isEmpty()) { f.devices = new ArrayList<>(); } boolean found = false; for (FolderDeviceConfig d : f.devices) { if (StringUtils.equals(d.deviceID, device.deviceID)) { found = true; break; } } if (found && !wants) { f.devices.remove(new FolderDeviceConfig(device.deviceID)); } else if (!found && wants) { f.devices.add(new FolderDeviceConfig(device.deviceID)); } } } } return config; }) // send our edited config back to the server .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription deleteDevice(DeviceConfig device, Action1<Throwable> onError, Action0 onComplete) { return restApi.deviceId(device.deviceID) // got normalized device id // need to get config and remove device with matching .zipWith(restApi.config(), (deviceId, config) -> { if (deviceId.id == null) { throw new NullPointerException(deviceId.error); } Timber.d("deleteDevice() updating deviceId %s -> %s", device.deviceID, deviceId.id); device.deviceID = deviceId.id; if (!config.devices.remove(device)) { throw new IllegalArgumentException("Device not found in config " + device.deviceID); } return config; }) // send our altered config back to server .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription ignoreDevice(String id, Action1<Throwable> onError, Action0 onComplete) { return restApi.deviceId(id) .zipWith(restApi.config(), (deviceId, config) -> { if (deviceId.id == null) { throw new NullPointerException(deviceId.error); } if (!config.ignoredDevices.contains(deviceId.id)) { config.ignoredDevices.add(deviceId.id); } return config; }) .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription editSettings(DeviceConfig thisDevice, OptionsConfig options, GUIConfig guiConfig, Action1<Throwable> onError, Action0 onComplete) { return restApi.config() .map(config -> { int idx = config.devices.indexOf(thisDevice); config.devices.set(idx, thisDevice); config.options = options; config.gui = guiConfig; return config; }) .flatMap(restApi::updateConfig) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignoreOnNext(), onError, onComplete ); } public Subscription getIgnores(String id, Action1<Ignores> onNext, Action1<Throwable> onError) { return restApi.ignores(id) .subscribeOn(AndroidSchedulers.mainThread()) .subscribe(onNext, onError); } public Subscription editIgnores(String id, Ignores ignores, Action1<Ignores> onNext, Action1<Throwable> onError, Action0 onComplete) { return restApi.updateIgnores(id, ignores) .observeOn(AndroidSchedulers.mainThread()) .subscribe(onNext, onError, onComplete); } public void restart() { final Scheduler.Worker worker = subscribeOn.createWorker(); worker.schedule(() -> { synchronized (lock) { restarting = true; eventMonitor.resetCounter(); updateState(false); } unsubscribeActiveSubscriptions(); //synchronous call SynchingApiWrapper.unwrap(restApi) .restart().subscribe(ignoreOnNext(), SessionController.this::logException); worker.unsubscribe(); }); } public void shutdown() { //push to worker thread so we can stop event monitor final Scheduler.Worker worker = subscribeOn.createWorker(); worker.schedule(() -> { synchronized (lock) { restarting = false; eventMonitor.stop(); updateState(false); postChange(Change.FAILURE); } unsubscribeActiveSubscriptions(); //synchronous call SynchingApiWrapper.unwrap(restApi) .shutdown().subscribe(ignoreOnNext(), SessionController.this::logException); worker.unsubscribe(); }); } public Observable<List<String>> getAutoCompleteDirectoryList(String current) { //intentionally not setting observeOn return restApi.autocompleteDirectory(current); } public Observable<Bitmap> getQRImage(String deviceId) { //intentionally not setting observeOn return restApi.qr(deviceId) .map(resp -> { InputStream in = null; try { in = resp.byteStream(); return BitmapFactory.decodeStream(in); } catch (IOException e) { throw new RuntimeException(e); } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(resp); } }); } public Subscription overrideChanges(String id) { return restApi.override(id) .subscribe(ignoreOnNext(), this::logException); } public Subscription scanFolder(String id) { return restApi.scan(id) .subscribe(ignoreOnNext(), this::logException); } public Subscription pauseDevice(String deviceId) { return restApi.pause(deviceId) .subscribe(ignoreOnNext(), this::logException); } public Subscription resumeDevice(String deviceId) { return restApi.resume(deviceId) .subscribe(ignoreOnNext(), this::logException); } void logException(Throwable e) { Timber.e(e, "%s: %s", e.getClass().getSimpleName(), e.getMessage()); } void logException(Throwable e, String key) { Timber.e(e, "%s: %s", key, e.getMessage()); removeSubscription(key); } void addSubscription(String key, Subscription subscription) { synchronized (activeSubscriptions) { activeSubscriptions.put(key, subscription); } } @Nullable Subscription removeSubscription(String key) { synchronized (activeSubscriptions) { return activeSubscriptions.remove(key); } } boolean hasActiveSubscription(String key) { synchronized (activeSubscriptions) { return activeSubscriptions.containsKey(key); } } void unsubscribeActiveSubscriptions() { synchronized (activeSubscriptions) { for (Subscription s : activeSubscriptions.values()) { if (s != null) s.unsubscribe(); } activeSubscriptions.clear(); } } private static <T> Observable<T> retryOnce(Observable<T> o) { return Observable.defer(() -> o).retry(1); } private static <T> Action1<T> ignoreOnNext() { return t -> {}; } public Subscription subscribeChanges(Action1<ChangeEvent> onNext, Change... changes) { Observable<ChangeEvent> o; if (changes.length == 0) { o = changeBus; } else { o = changeBus .filter(c -> { for (Change cc : changes) { if (c.change == cc) return true; } return false; }); } final boolean online; synchronized (lock) { online = this.online; } return o.onBackpressureBuffer() //always post online event for new subscribers .startWith(new ChangeEvent(online ? Change.ONLINE : Change.OFFLINE, null)) .observeOn(AndroidSchedulers.mainThread()).subscribe(onNext); } }