1
0
Fork 0
mirror of https://github.com/syncthing/syncthing-android.git synced 2024-11-22 20:31:16 +00:00

Fix incorrect remote device syncing status UI (fixes #1062)

This commit is contained in:
Catfriend1 2018-05-09 19:54:58 +02:00 committed by Audrius Butkevicius
parent 7e3c6c0b8f
commit f13ed587d7
9 changed files with 291 additions and 108 deletions

View file

@ -87,7 +87,7 @@ public class FolderListFragment extends ListFragment implements SyncthingService
mAdapter.clear();
List<Folder> folders = activity.getApi().getFolders();
mAdapter.addAll(folders);
mAdapter.updateModel(activity.getApi());
mAdapter.updateFolderStatus(activity.getApi());
mAdapter.notifyDataSetChanged();
setListShown(true);
}

View file

@ -20,7 +20,7 @@ public class GetRequest extends ApiRequest {
public static final String URI_VERSION = "/rest/system/version";
public static final String URI_SYSTEM = "/rest/system/status";
public static final String URI_CONNECTIONS = "/rest/system/connections";
public static final String URI_MODEL = "/rest/db/status";
public static final String URI_STATUS = "/rest/db/status";
public static final String URI_DEVICEID = "/rest/svc/deviceid";
public static final String URI_REPORT = "/rest/svc/report";
public static final String URI_EVENTS = "/rest/events";

View file

@ -0,0 +1,141 @@
package com.nutomic.syncthingandroid.model;
import android.util.Log;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* This class caches remote folder and device synchronization
* completion indicators defined in {@link CompletionInfo#CompletionInfo}
* according to syncthing's REST "/completion" JSON result schema.
* Completion model of syncthing's web UI is completion[deviceId][folderId]
*/
public class Completion {
private static final String TAG = "Completion";
HashMap<String, HashMap<String, CompletionInfo>> deviceFolderMap =
new HashMap<String, HashMap<String, CompletionInfo>>();
/**
* Removes a folder from the cache model.
*/
private void removeFolder(String folderId) {
for (HashMap<String, CompletionInfo> folderMap : deviceFolderMap.values()) {
if (folderMap.containsKey(folderId)) {
folderMap.remove(folderId);
break;
}
}
}
/**
* Updates device and folder information in the cache model
* after a config update.
*/
public void updateFromConfig(List<Device> newDevices, List<Folder> newFolders) {
HashMap<String, CompletionInfo> folderMap;
// Handle devices that were removed from the config.
List<String> removedDevices = new ArrayList<>();;
Boolean deviceFound;
for (String deviceId : deviceFolderMap.keySet()) {
deviceFound = false;
for (Device device : newDevices) {
if (device.deviceID.equals(deviceId)) {
deviceFound = true;
break;
}
}
if (!deviceFound) {
removedDevices.add(deviceId);
}
}
for (String deviceId : removedDevices) {
Log.v(TAG, "updateFromConfig: Remove device '" + deviceId + "' from cache model");
deviceFolderMap.remove(deviceId);
}
// Handle devices that were added to the config.
for (Device device : newDevices) {
if (!deviceFolderMap.containsKey(device.deviceID)) {
Log.v(TAG, "updateFromConfig: Add device '" + device.deviceID + "' to cache model");
deviceFolderMap.put(device.deviceID, new HashMap<String, CompletionInfo>());
}
}
// Handle folders that were removed from the config.
List<String> removedFolders = new ArrayList<>();;
Boolean folderFound;
for (Map.Entry<String, HashMap<String, CompletionInfo>> device : deviceFolderMap.entrySet()) {
for (String folderId : device.getValue().keySet()) {
folderFound = false;
for (Folder folder : newFolders) {
if (folder.id.equals(folderId)) {
folderFound = true;
break;
}
}
if (!folderFound) {
removedFolders.add(folderId);
}
}
}
for (String folderId : removedFolders) {
Log.v(TAG, "updateFromConfig: Remove folder '" + folderId + "' from cache model");
removeFolder(folderId);
}
// Handle folders that were added to the config.
for (Folder folder : newFolders) {
for (Device device : newDevices) {
if (folder.getDevice(device.deviceID) != null) {
// folder is shared with device.
folderMap = deviceFolderMap.get(device.deviceID);
if (!folderMap.containsKey(folder.id)) {
Log.v(TAG, "updateFromConfig: Add folder '" + folder.id +
"' shared with device '" + device.deviceID + "' to cache model.");
folderMap.put(folder.id, new CompletionInfo());
}
}
}
}
}
/**
* Calculates remote device sync completion percentage across all folders
* shared with the device.
*/
public int getDeviceCompletion(String deviceId) {
int folderCount = 0;
double sumCompletion = 0;
HashMap<String, CompletionInfo> folderMap = deviceFolderMap.get(deviceId);
if (folderMap != null) {
for (Map.Entry<String, CompletionInfo> folder : folderMap.entrySet()) {
sumCompletion += folder.getValue().completion;
folderCount++;
}
}
if (folderCount == 0) {
return 100;
} else {
return (int) Math.floor(sumCompletion / folderCount);
}
}
/**
* Set completionInfo within the completion[deviceId][folderId] model.
*/
public void setCompletionInfo(String deviceId, String folderId,
CompletionInfo completionInfo) {
// Add device parent node if it does not exist.
if (!deviceFolderMap.containsKey(deviceId)) {
deviceFolderMap.put(deviceId, new HashMap<String, CompletionInfo>());
}
// Add folder or update existing folder entry.
deviceFolderMap.get(deviceId).put(folderId, completionInfo);
}
}

View file

@ -0,0 +1,23 @@
package com.nutomic.syncthingandroid.model;
/**
* According to syncthing REST API
* https://docs.syncthing.net/rest/db-completion-get.html
*
* completion is also returned by the events API
* https://docs.syncthing.net/events/foldercompletion.html
*
*/
public class CompletionInfo {
public double completion = 100;
/**
* The following values are only returned by the REST API call
* to ""/completion". We will need them in the future to show
* more statistics in the device UI.
*/
// public long globalBytes = 0;
// public long needBytes = 0;
// public long needDeletes = 0;
// public long needItems = 0;
}

View file

@ -1,6 +1,6 @@
package com.nutomic.syncthingandroid.model;
public class Model {
public class FolderStatus {
public long globalBytes;
public long globalDeleted;
public long globalDirectories;

View file

@ -13,10 +13,12 @@ import android.provider.MediaStore;
import android.util.Log;
import com.annimon.stream.Stream;
import com.nutomic.syncthingandroid.BuildConfig;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.activities.DeviceActivity;
import com.nutomic.syncthingandroid.activities.FolderActivity;
import com.nutomic.syncthingandroid.model.CompletionInfo;
import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.model.Event;
import com.nutomic.syncthingandroid.model.Folder;
@ -93,6 +95,9 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
*/
@Override
public void onEvent(Event event) {
String deviceId;
String folderId;
switch (event.type) {
case "ConfigSaved":
if (mApi != null) {
@ -101,8 +106,8 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
}
break;
case "DeviceRejected":
String deviceId = (String) event.data.get("device");
Log.d(TAG, "Unknwon device " + deviceId + " wants to connect");
deviceId = (String) event.data.get("device");
Log.d(TAG, "Unknown device " + deviceId + " wants to connect");
Intent intent = new Intent(mContext, DeviceActivity.class)
.putExtra(DeviceActivity.EXTRA_IS_CREATE, true)
@ -117,9 +122,16 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
notify(title, pi);
break;
case "FolderCompletion":
deviceId = (String) event.data.get("device");
folderId = (String) event.data.get("folder");
CompletionInfo completionInfo = new CompletionInfo();
completionInfo.completion = (Double) event.data.get("completion");
mApi.setCompletionInfo(deviceId, folderId, completionInfo);
break;
case "FolderRejected":
deviceId = (String) event.data.get("device");
String folderId = (String) event.data.get("folder");
folderId = (String) event.data.get("folder");
String folderLabel = (String) event.data.get("folderLabel");
Log.d(TAG, "Device " + deviceId + " wants to share folder " + folderId);
@ -170,6 +182,25 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
case "Ping":
// Ignored.
break;
case "DeviceConnected":
case "DeviceDisconnected":
case "DeviceDiscovered":
case "DownloadProgress":
case "FolderPaused":
case "FolderScanProgress":
case "FolderSummary":
case "ItemStarted":
case "LocalIndexUpdated":
case "LoginAttempt":
case "RemoteDownloadProgress":
case "RemoteIndexUpdated":
case "Starting":
case "StartupComplete":
case "StateChanged":
if (BuildConfig.DEBUG) {
Log.v(TAG, "Ignored event " + event.type + ", data " + event.data);
}
break;
default:
Log.v(TAG, "Unhandled event " + event.type);
}

View file

@ -24,11 +24,13 @@ 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.Completion;
import com.nutomic.syncthingandroid.model.CompletionInfo;
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.FolderStatus;
import com.nutomic.syncthingandroid.model.Options;
import com.nutomic.syncthingandroid.model.SystemInfo;
import com.nutomic.syncthingandroid.model.SystemVersion;
@ -95,10 +97,14 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
private long mPreviousConnectionTime = 0;
/**
* Stores the latest result of {@link #getModel} for each folder, for calculating device
* percentage in {@link #getConnections}.
* Stores the latest result of {@link #getFolderStatus} for each folder
*/
private final HashMap<String, Model> mCachedModelInfo = new HashMap<>();
private HashMap<String, FolderStatus> mCachedFolderStatuses = new HashMap<>();
/**
* Stores the latest result of device and folder completion events.
*/
private Completion mCompletion = new Completion();
@Inject NotificationHandler mNotificationHandler;
@ -143,11 +149,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
tryIsAvailable();
});
new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, result -> {
Log.v(TAG, "onWebGuiAvailable: " + result);
mConfig = new Gson().fromJson(result, Config.class);
if (mConfig == null) {
throw new RuntimeException("config is null: " + result);
}
onReloadConfigComplete(result);
tryIsAvailable();
});
getSystemInfo(info -> {
@ -157,13 +159,18 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
}
public void reloadConfig() {
new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, result -> {
Log.v(TAG, "reloadConfig: " + result);
mConfig = new Gson().fromJson(result, Config.class);
if (mConfig == null) {
throw new RuntimeException("config is null: " + result);
}
});
new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, this::onReloadConfigComplete);
}
private void onReloadConfigComplete(String result) {
Log.v(TAG, "onReloadConfigComplete: " + result);
mConfig = new Gson().fromJson(result, Config.class);
if (mConfig == null) {
throw new RuntimeException("config is null: " + result);
}
// Update cached device and folder information stored in the mCompletion model.
mCompletion.updateFromConfig(getDevices(true), getFolders());
}
/**
@ -241,6 +248,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
public void removeFolder(String id) {
removeFolderInternal(id);
// mCompletion will be updated after the ConfigSaved event.
sendConfig();
// Remove saved data from share activity for this folder.
PreferenceManager.getDefaultSharedPreferences(mContext).edit()
@ -300,6 +308,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
public void removeDevice(String deviceId) {
removeDeviceInternal(deviceId);
// mCompletion will be updated after the ConfigSaved event.
sendConfig();
}
@ -374,7 +383,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
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());
e.getValue().completion = mCompletion.getDeviceCompletion(e.getKey());
Connections.Connection prev =
(mPreviousConnections.isPresent() && mPreviousConnections.get().connections.containsKey(e.getKey()))
@ -390,46 +399,14 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
});
}
/**
* 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, mApiKey,
public void getFolderStatus(final String folderId, final OnResultListener2<String, FolderStatus> listener) {
new GetRequest(mContext, mUrl, GetRequest.URI_STATUS, mApiKey,
ImmutableMap.of("folder", folderId), result -> {
Model m = new Gson().fromJson(result, Model.class);
mCachedModelInfo.put(folderId, m);
FolderStatus m = new Gson().fromJson(result, FolderStatus.class);
mCachedFolderStatuses.put(folderId, m);
listener.onResult(folderId, m);
});
}
@ -494,6 +471,14 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
});
}
/**
* Updates cached folder and device completion info according to event data.
*/
public void setCompletionInfo(String deviceId, String folderId, CompletionInfo completionInfo) {
mCompletion.setCompletionInfo(deviceId, folderId, completionInfo);
}
/**
* Returns prettyfied usage report.
*/

View file

@ -271,7 +271,7 @@ public class ConfigXml {
}
/**
* Set model name as device name for Syncthing.
* Set device model name as device name for Syncthing.
*
* We need to iterate through XML nodes manually, as mConfig.getDocumentElement() will also
* return nested elements inside folder element. We have to check that we only rename the
@ -300,12 +300,12 @@ public class ConfigXml {
private boolean changeDefaultFolder() {
Element folder = (Element) mConfig.getDocumentElement()
.getElementsByTagName("folder").item(0);
String model = Build.MODEL
String deviceModel = Build.MODEL
.replace(" ", "_")
.toLowerCase(Locale.US)
.replaceAll("[^a-z0-9_-]", "");
folder.setAttribute("label", mContext.getString(R.string.default_folder_label));
folder.setAttribute("id", mContext.getString(R.string.default_folder_id, model));
folder.setAttribute("id", mContext.getString(R.string.default_folder_id, deviceModel));
folder.setAttribute("path", Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath());
folder.setAttribute("type", "readonly");

View file

@ -15,11 +15,10 @@ import android.widget.ArrayAdapter;
import android.widget.TextView;
import android.widget.Toast;
import com.nutomic.syncthingandroid.BuildConfig;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.databinding.ItemFolderListBinding;
import com.nutomic.syncthingandroid.model.Folder;
import com.nutomic.syncthingandroid.model.Model;
import com.nutomic.syncthingandroid.model.FolderStatus;
import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.util.Util;
@ -36,7 +35,7 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
private static final String TAG = "FoldersAdapter";
private final HashMap<String, Model> mModels = new HashMap<>();
private final HashMap<String, FolderStatus> mLocalFolderStatuses = new HashMap<>();
public FoldersAdapter(Context context) {
super(context, 0);
@ -50,7 +49,6 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
: DataBindingUtil.bind(convertView);
Folder folder = getItem(position);
Model model = mModels.get(folder.id);
binding.label.setText(TextUtils.isEmpty(folder.label) ? folder.id : folder.label);
binding.directory.setText(folder.path);
binding.openFolder.setOnClickListener(v -> {
@ -73,48 +71,53 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
}
});
if (model != null) {
int percentage = (model.globalBytes != 0)
? Math.round(100 * model.inSyncBytes / model.globalBytes)
: 100;
long neededItems = model.needFiles + model.needDirectories + model.needSymlinks + model.needDeletes;
if (model.state.equals("idle") && neededItems > 0) {
binding.state.setText(getContext().getString(R.string.status_outofsync));
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red));
} else {
if (folder.paused) {
binding.state.setText(getContext().getString(R.string.state_paused));
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_black));
} else {
binding.state.setText(getLocalizedState(getContext(), model.state, percentage));
switch(model.state) {
case "idle":
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_green));
break;
case "scanning":
case "syncing":
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_blue));
break;
default:
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red));
}
}
}
binding.items.setVisibility(VISIBLE);
binding.items.setText(getContext().getResources()
.getQuantityString(R.plurals.files, (int) model.inSyncFiles, model.inSyncFiles, model.globalFiles));
binding.size.setVisibility(VISIBLE);
binding.size.setText(getContext().getString(R.string.folder_size_format,
Util.readableFileSize(getContext(), model.inSyncBytes),
Util.readableFileSize(getContext(), model.globalBytes)));
setTextOrHide(binding.invalid, model.invalid);
} else {
updateFolderStatusView(binding, folder);
return binding.getRoot();
}
private void updateFolderStatusView(ItemFolderListBinding binding, Folder folder) {
FolderStatus folderStatus = mLocalFolderStatuses.get(folder.id);
if (folderStatus == null) {
binding.items.setVisibility(GONE);
binding.size.setVisibility(GONE);
setTextOrHide(binding.invalid, folder.invalid);
return;
}
return binding.getRoot();
int percentage = (folderStatus.globalBytes != 0)
? Math.round(100 * folderStatus.inSyncBytes / folderStatus.globalBytes)
: 100;
long neededItems = folderStatus.needFiles + folderStatus.needDirectories + folderStatus.needSymlinks + folderStatus.needDeletes;
if (folderStatus.state.equals("idle") && neededItems > 0) {
binding.state.setText(getContext().getString(R.string.status_outofsync));
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red));
} else {
if (folder.paused) {
binding.state.setText(getContext().getString(R.string.state_paused));
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_black));
} else {
binding.state.setText(getLocalizedState(getContext(), folderStatus.state, percentage));
switch(folderStatus.state) {
case "idle":
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_green));
break;
case "scanning":
case "syncing":
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_blue));
break;
default:
binding.state.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red));
}
}
}
binding.items.setVisibility(VISIBLE);
binding.items.setText(getContext().getResources()
.getQuantityString(R.plurals.files, (int) folderStatus.inSyncFiles, folderStatus.inSyncFiles, folderStatus.globalFiles));
binding.size.setVisibility(VISIBLE);
binding.size.setText(getContext().getString(R.string.folder_size_format,
Util.readableFileSize(getContext(), folderStatus.inSyncBytes),
Util.readableFileSize(getContext(), folderStatus.globalBytes)));
setTextOrHide(binding.invalid, folderStatus.invalid);
}
/**
@ -132,16 +135,16 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
}
/**
* Requests updated model info from the api for all visible items.
* Requests updated folder status from the api for all visible items.
*/
public void updateModel(RestApi api) {
public void updateFolderStatus(RestApi api) {
for (int i = 0; i < getCount(); i++) {
api.getModel(getItem(i).id, this::onReceiveModel);
api.getFolderStatus(getItem(i).id, this::onReceiveFolderStatus);
}
}
private void onReceiveModel(String folderId, Model model) {
mModels.put(folderId, model);
private void onReceiveFolderStatus(String folderId, FolderStatus folderStatus) {
mLocalFolderStatuses.put(folderId, folderStatus);
notifyDataSetChanged();
}