Lots of new unit tests, refactoring.

New tests:
RestApiTest
NodesAdapterTest
ReposAdapterTest

Refactored:
extracted PollWebGuiAvailableTask from SyncthingService
some changes in return values/calling behaviour for easier/better testing
This commit is contained in:
Felix Ableitner 2014-08-24 16:37:14 +02:00
parent 7b3d1b4052
commit b5f38c5c19
12 changed files with 561 additions and 149 deletions

View File

@ -0,0 +1,53 @@
package com.nutomic.syncthingandroid.test.syncthing;
import android.test.AndroidTestCase;
import com.nutomic.syncthingandroid.syncthing.PollWebGuiAvailableTask;
import com.nutomic.syncthingandroid.syncthing.PostTask;
import com.nutomic.syncthingandroid.syncthing.RestApi;
import com.nutomic.syncthingandroid.syncthing.SyncthingRunnable;
import com.nutomic.syncthingandroid.syncthing.SyncthingService;
import com.nutomic.syncthingandroid.test.TestContext;
import com.nutomic.syncthingandroid.util.ConfigXml;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class PollWebGuiAvailableTaskTest extends AndroidTestCase {
private SyncthingRunnable mSyncthing;
private ConfigXml mConfig;
@Override
protected void setUp() throws Exception {
super.setUp();
mConfig = new ConfigXml(new TestContext(getContext()));
mConfig.updateIfNeeded();
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
ConfigXml.getConfigFile(new TestContext(getContext())).delete();
}
public void testPolling() throws InterruptedException {
mSyncthing = new SyncthingRunnable(new TestContext(null),
getContext().getApplicationInfo().dataDir + "/" + SyncthingService.BINARY_NAME);
final CountDownLatch latch = new CountDownLatch(1);
new PollWebGuiAvailableTask() {
@Override
protected void onPostExecute(Void aVoid) {
latch.countDown();
}
}.execute(mConfig.getWebGuiUrl());
latch.await(1, TimeUnit.SECONDS);
new PostTask().execute(mConfig.getWebGuiUrl(), PostTask.URI_SHUTDOWN, mConfig.getApiKey());
}
}

View File

@ -0,0 +1,189 @@
package com.nutomic.syncthingandroid.test.syncthing;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.LargeTest;
import android.test.suitebuilder.annotation.MediumTest;
import android.test.suitebuilder.annotation.SmallTest;
import com.nutomic.syncthingandroid.syncthing.PollWebGuiAvailableTask;
import com.nutomic.syncthingandroid.syncthing.PostTask;
import com.nutomic.syncthingandroid.syncthing.RestApi;
import com.nutomic.syncthingandroid.syncthing.SyncthingRunnable;
import com.nutomic.syncthingandroid.syncthing.SyncthingService;
import com.nutomic.syncthingandroid.test.TestContext;
import com.nutomic.syncthingandroid.util.ConfigXml;
import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class RestApiTest extends AndroidTestCase {
private SyncthingRunnable mSyncthing;
private ConfigXml mConfig;
private RestApi mApi;
@Override
protected void setUp() throws Exception {
super.setUp();
mSyncthing = new SyncthingRunnable(new TestContext(null),
getContext().getApplicationInfo().dataDir + "/" + SyncthingService.BINARY_NAME);
mConfig = new ConfigXml(new TestContext(getContext()));
mConfig.createCameraRepo();
mConfig.updateIfNeeded();
final CountDownLatch latch = new CountDownLatch(2);
new PollWebGuiAvailableTask() {
@Override
protected void onPostExecute(Void aVoid) {
mApi.onWebGuiAvailable();
latch.countDown();
}
}.execute(mConfig.getWebGuiUrl());
mApi = new RestApi(getContext(), mConfig.getWebGuiUrl(), mConfig.getApiKey(),
new RestApi.OnApiAvailableListener() {
@Override
public void onApiAvailable() {
latch.countDown();
}
});
latch.await(1, TimeUnit.SECONDS);
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
final CountDownLatch latch = new CountDownLatch(1);
new PostTask() {
@Override
protected void onPostExecute(Boolean aBoolean) {
assertTrue(aBoolean);
latch.countDown();
}
}.execute(mConfig.getWebGuiUrl(), PostTask.URI_SHUTDOWN, mConfig.getApiKey());
latch.await(1, TimeUnit.SECONDS);
ConfigXml.getConfigFile(new TestContext(getContext())).delete();
}
@SmallTest
public void testGetVersion() {
assertNotNull(mApi.getVersion());
assertFalse(mApi.getVersion().equals(""));
}
@SmallTest
public void testGetNodes() {
assertNotNull(mApi.getNodes());
}
@MediumTest
public void testGetSystemInfo() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
mApi.getSystemInfo(new RestApi.OnReceiveSystemInfoListener() {
@Override
public void onReceiveSystemInfo(RestApi.SystemInfo info) {
assertNotNull(info);
latch.countDown();
}
});
latch.await(1, TimeUnit.SECONDS);
}
@SmallTest
public void testGetRepos() {
assertNotNull(mApi.getRepos());
}
@SmallTest
public void testReadableFileSize() {
assertEquals("1 MB", RestApi.readableFileSize(getContext(), 1048576));
assertEquals("1 GB", RestApi.readableFileSize(getContext(), 1073741824));
}
@SmallTest
public void testGetReadableTransferRate() {
assertEquals("1 Mb/s", RestApi.readableTransferRate(getContext(), 1048576));
assertEquals("1 Gb/s", RestApi.readableTransferRate(getContext(), 1073741824));
}
@MediumTest
public void testGetConnections() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
mApi.getConnections(new RestApi.OnReceiveConnectionsListener() {
@Override
public void onReceiveConnections(Map<String, RestApi.Connection> connections) {
assertNotNull(connections);
latch.countDown();
}
});
latch.await(1, TimeUnit.SECONDS);
}
@MediumTest
public void testGetModel() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
mApi.getModel("camera", new RestApi.OnReceiveModelListener() {
@Override
public void onReceiveModel(String repoId, RestApi.Model model) {
assertNotNull(model);
latch.countDown();
}
});
latch.await(1, TimeUnit.SECONDS);
}
@LargeTest
public void testModifyNode() throws InterruptedException {
final RestApi.Node node = new RestApi.Node();
node.NodeID = "P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2";
node.Addresses = "dynamic";
node.Name = "my node";
final CountDownLatch latch = new CountDownLatch(1);
mApi.editNode(node, new RestApi.OnNodeIdNormalizedListener() {
@Override
public void onNodeIdNormalized(String normalizedId, String error) {
assertEquals(node.NodeID, normalizedId);
assertEquals(null, error);
latch.countDown();
}
});
latch.await(10, TimeUnit.SECONDS);
assertTrue(mApi.deleteNode(node, getContext()));
}
@SmallTest
public void testModifyRepo() {
RestApi.Repo repo = new RestApi.Repo();
repo.Directory = "/my/dir";
repo.ID = "my-repo";
repo.Nodes = new ArrayList<>();
repo.ReadOnly = false;
repo.Versioning = new RestApi.Versioning();
assertTrue(mApi.editRepo(repo, true, getContext()));
assertTrue(mApi.deleteRepo(repo, getContext()));
}
@MediumTest
public void testNormalizeNodeId() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
mApi.normalizeNodeId("p56ioi7m--zjnu2iq-gdr-eydm-2mgtmgl3bxnpq6w5btbbz4tjxzwicq",
new RestApi.OnNodeIdNormalizedListener() {
@Override
public void onNodeIdNormalized(String normalizedId, String error) {
assertEquals("P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2",
normalizedId);
latch.countDown();
}
});
latch.await(1, TimeUnit.SECONDS);
}
}

View File

@ -0,0 +1,67 @@
package com.nutomic.syncthingandroid.test.util;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.MediumTest;
import android.view.View;
import android.widget.TextView;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.syncthing.RestApi;
import com.nutomic.syncthingandroid.test.TestContext;
import com.nutomic.syncthingandroid.util.NodesAdapter;
import com.nutomic.syncthingandroid.util.ReposAdapter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
public class NodesAdapterTest extends AndroidTestCase {
private NodesAdapter mAdapter;
private RestApi.Node mNode = new RestApi.Node();
private RestApi.Connection mConnection = new RestApi.Connection();
@Override
protected void setUp() throws Exception {
super.setUp();
mAdapter = new NodesAdapter(getContext());
mNode.Addresses = "127.0.0.1:12345";
mNode.Name = "the node";
mNode.NodeID = "123-456-789";
mConnection.Completion = 100;
mConnection.InBits = 1048576;
mConnection.OutBits = 1073741824;
}
@MediumTest
public void testGetViewNoConnections() {
mAdapter.add(Arrays.asList(mNode));
View v = mAdapter.getView(0, null, null);
assertEquals(mNode.Name, ((TextView) v.findViewById(R.id.name)).getText());
assertEquals(getContext().getString(R.string.node_disconnected),
((TextView) v.findViewById(R.id.status)).getText().toString());
assertFalse(((TextView) v.findViewById(R.id.status)).getText().equals(""));
assertFalse(((TextView) v.findViewById(R.id.download)).getText().equals(""));
assertFalse(((TextView) v.findViewById(R.id.upload)).getText().equals(""));
}
@MediumTest
public void testGetViewConnections() {
mAdapter.add(Arrays.asList(mNode));
mAdapter.onReceiveConnections(
new HashMap<String, RestApi.Connection>() {{ put(mNode.NodeID, mConnection); }});
View v = mAdapter.getView(0, null, null);
assertEquals(getContext().getString(R.string.node_up_to_date),
((TextView) v.findViewById(R.id.status)).getText().toString());
assertEquals("1 Mb/s", ((TextView) v.findViewById(R.id.download)).getText().toString());
assertEquals("1 Gb/s", ((TextView) v.findViewById(R.id.upload)).getText().toString());
}
}

View File

@ -0,0 +1,65 @@
package com.nutomic.syncthingandroid.test.util;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.MediumTest;
import android.view.View;
import android.widget.TextView;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.syncthing.RestApi;
import com.nutomic.syncthingandroid.test.TestContext;
import com.nutomic.syncthingandroid.util.ReposAdapter;
import java.util.ArrayList;
import java.util.Arrays;
public class ReposAdapterTest extends AndroidTestCase {
private ReposAdapter mAdapter;
private RestApi.Repo mRepo = new RestApi.Repo();
private RestApi.Model mModel = new RestApi.Model();
@Override
protected void setUp() throws Exception {
super.setUp();
mAdapter = new ReposAdapter(getContext());
mRepo.Directory = "/my/dir/";
mRepo.ID = "id 123";
mRepo.Invalid = "all good";
mRepo.Nodes = new ArrayList<>();
mRepo.ReadOnly = false;
mRepo.Versioning = new RestApi.Versioning();
mModel.localFiles = 50;
mModel.globalFiles = 500;
mModel.localBytes = 1048576;
mModel.globalBytes = 1073741824;
}
@MediumTest
public void testGetViewNoModel() {
mAdapter.add(Arrays.asList(mRepo));
View v = mAdapter.getView(0, null, null);
assertEquals(mRepo.ID, ((TextView) v.findViewById(R.id.id)).getText());
assertEquals(mRepo.Directory, ((TextView) v.findViewById(R.id.directory)).getText());
assertEquals(mRepo.Invalid, ((TextView) v.findViewById(R.id.invalid)).getText());
}
@MediumTest
public void testGetViewModel() {
mAdapter.add(Arrays.asList(mRepo));
mAdapter.onReceiveModel(mRepo.ID, mModel);
View v = mAdapter.getView(0, null, null);
assertFalse(((TextView) v.findViewById(R.id.state)).getText().toString().equals(""));
String items = ((TextView) v.findViewById(R.id.items)).getText().toString();
assertTrue(items.contains(Long.toString(mModel.localFiles)));
assertTrue(items.contains(Long.toString(mModel.localFiles)));
String size = ((TextView) v.findViewById(R.id.size)).getText().toString();
assertTrue(size.contains("1 MB"));
assertTrue(size.contains("1 GB"));
}
}

View File

@ -35,7 +35,7 @@ public class WebGuiActivity extends SyncthingActivity implements SyncthingServic
/** /**
* Initialize WebView. * Initialize WebView.
* <p/> *
* Ignore lint javascript warning as js is loaded only from our known, local service. * Ignore lint javascript warning as js is loaded only from our known, local service.
*/ */
@Override @Override
@ -66,7 +66,7 @@ public class WebGuiActivity extends SyncthingActivity implements SyncthingServic
*/ */
@Override @Override
public void onWebGuiAvailable() { public void onWebGuiAvailable() {
mWebView.loadUrl(getApi().getUrl()); mWebView.loadUrl(getService().getWebGuiUrl());
} }
} }

View File

@ -0,0 +1,57 @@
package com.nutomic.syncthingandroid.syncthing;
import android.os.AsyncTask;
import android.util.Log;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.conn.HttpHostConnectException;
import org.apache.http.impl.client.DefaultHttpClient;
import java.io.IOException;
/**
* Polls SYNCTHING_URL until it returns HTTP status OK, then calls all listeners
* in mOnWebGuiAvailableListeners and clears it.
*/
public abstract class PollWebGuiAvailableTask extends AsyncTask<String, Void, Void> {
private static final String TAG = "PollWebGuiAvailableTask";
/**
* Interval in ms, at which connections to the web gui are performed on first start
* to find out if it's online.
*/
private static final long WEB_GUI_POLL_INTERVAL = 100;
/**
* @param url The URL of the web GUI (eg 127.0.0.1:8080).
*/
@Override
protected Void doInBackground(String... url) {
int status = 0;
HttpClient httpclient = new DefaultHttpClient();
HttpHead head = new HttpHead(url[0]);
do {
try {
Thread.sleep(WEB_GUI_POLL_INTERVAL);
HttpResponse response = httpclient.execute(head);
// NOTE: status is not really needed, as HttpHostConnectException is thrown
// earlier.
status = response.getStatusLine().getStatusCode();
} catch (HttpHostConnectException e) {
// We catch this in every call, as long as the service is not online,
// so we ignore and continue.
} catch (IOException e) {
Log.w(TAG, "Failed to poll for web interface", e);
} catch (InterruptedException e) {
Log.w(TAG, "Failed to poll for web interface", e);
}
} while (status != HttpStatus.SC_OK);
return null;
}
}

View File

@ -14,7 +14,7 @@ import java.io.IOException;
/** /**
* Performs a POST request with no parameters to the URL in uri[0] with the path in uri[1]. * Performs a POST request with no parameters to the URL in uri[0] with the path in uri[1].
*/ */
public class PostTask extends AsyncTask<String, Void, Void> { public class PostTask extends AsyncTask<String, Void, Boolean> {
private static final String TAG = "PostTask"; private static final String TAG = "PostTask";
@ -31,7 +31,7 @@ public class PostTask extends AsyncTask<String, Void, Void> {
* params[3] The request content (optional) * params[3] The request content (optional)
*/ */
@Override @Override
protected Void doInBackground(String... params) { protected Boolean doInBackground(String... params) {
String fullUri = params[0] + params[1]; String fullUri = params[0] + params[1];
HttpClient httpclient = new DefaultHttpClient(); HttpClient httpclient = new DefaultHttpClient();
HttpPost post = new HttpPost(fullUri); HttpPost post = new HttpPost(fullUri);
@ -44,8 +44,9 @@ public class PostTask extends AsyncTask<String, Void, Void> {
httpclient.execute(post); httpclient.execute(post);
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, "Failed to call Rest API at " + fullUri, e); Log.w(TAG, "Failed to call Rest API at " + fullUri, e);
return false;
} }
return null; return true;
} }
} }

View File

@ -134,7 +134,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
private static final int NOTIFICATION_RESTART = 2; private static final int NOTIFICATION_RESTART = 2;
private final SyncthingService mSyncthingService; private final Context mContext;
private String mVersion; private String mVersion;
@ -165,28 +165,15 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
* Stores the latest result of {@link #getModel(String, OnReceiveModelListener)} for each repo, * Stores the latest result of {@link #getModel(String, OnReceiveModelListener)} for each repo,
* for calculating node percentage in {@link #getConnections(OnReceiveConnectionsListener)}. * for calculating node percentage in {@link #getConnections(OnReceiveConnectionsListener)}.
*/ */
private HashMap<String, Model> mCachedModelInfo = new HashMap<String, Model>(); private HashMap<String, Model> mCachedModelInfo = new HashMap<>();
public RestApi(SyncthingService syncthingService, String url, String apiKey) { public RestApi(Context context, String url, String apiKey, OnApiAvailableListener listener) {
mSyncthingService = syncthingService; mContext = context;
mUrl = url; mUrl = url;
mApiKey = apiKey; mApiKey = apiKey;
mNotificationManager = (NotificationManager) mNotificationManager = (NotificationManager)
mSyncthingService.getSystemService(Context.NOTIFICATION_SERVICE); mContext.getSystemService(Context.NOTIFICATION_SERVICE);
} mOnApiAvailableListener = listener;
/**
* Returns the full URL of the web gui.
*/
public String getUrl() {
return mUrl;
}
/**
* Returns the API key needed to access the Rest API.
*/
public String getApiKey() {
return mApiKey;
} }
/** /**
@ -199,6 +186,12 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
*/ */
private static final int TOTAL_STARTUP_CALLS = 3; private static final int TOTAL_STARTUP_CALLS = 3;
public interface OnApiAvailableListener {
public void onApiAvailable();
}
private OnApiAvailableListener mOnApiAvailableListener;
/** /**
* Gets local node id, syncthing version and config, then calls all OnApiAvailableListeners. * Gets local node id, syncthing version and config, then calls all OnApiAvailableListeners.
*/ */
@ -243,7 +236,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
throw new AssertionError("Too many startup calls"); throw new AssertionError("Too many startup calls");
} }
if (value == TOTAL_STARTUP_CALLS) { if (value == TOTAL_STARTUP_CALLS) {
mSyncthingService.onApiChange(); mOnApiAvailableListener.onApiAvailable();
} }
} }
@ -327,7 +320,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
if (mRestartPostponed) if (mRestartPostponed)
return; return;
final Intent intent = new Intent(mSyncthingService, SyncthingService.class) final Intent intent = new Intent(mContext, SyncthingService.class)
.setAction(SyncthingService.ACTION_RESTART); .setAction(SyncthingService.ACTION_RESTART);
AlertDialog.Builder builder = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) AlertDialog.Builder builder = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB)
@ -337,7 +330,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override @Override
public void onClick(DialogInterface dialogInterface, int i) { public void onClick(DialogInterface dialogInterface, int i) {
mSyncthingService.startService(intent); mContext.startService(intent);
} }
}) })
.setNegativeButton(R.string.restart_later, new DialogInterface.OnClickListener() { .setNegativeButton(R.string.restart_later, new DialogInterface.OnClickListener() {
@ -361,13 +354,13 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
* Creates a notification prompting the user to restart the app. * Creates a notification prompting the user to restart the app.
*/ */
private void createRestartNotification() { private void createRestartNotification() {
Intent intent = new Intent(mSyncthingService, SyncthingService.class) Intent intent = new Intent(mContext, SyncthingService.class)
.setAction(SyncthingService.ACTION_RESTART); .setAction(SyncthingService.ACTION_RESTART);
PendingIntent pi = PendingIntent.getService(mSyncthingService, 0, intent, 0); PendingIntent pi = PendingIntent.getService(mContext, 0, intent, 0);
Notification n = new NotificationCompat.Builder(mSyncthingService) Notification n = new NotificationCompat.Builder(mContext)
.setContentTitle(mSyncthingService.getString(R.string.restart_title)) .setContentTitle(mContext.getString(R.string.restart_title))
.setContentText(mSyncthingService.getString(R.string.restart_notification_text)) .setContentText(mContext.getString(R.string.restart_notification_text))
.setSmallIcon(R.drawable.ic_launcher) .setSmallIcon(R.drawable.ic_launcher)
.setContentIntent(pi) .setContentIntent(pi)
.build(); .build();
@ -381,13 +374,13 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
*/ */
public List<Node> getNodes() { public List<Node> getNodes() {
if (mConfig == null) if (mConfig == null)
return new ArrayList<Node>(); return null;
try { try {
return getNodes(mConfig.getJSONArray("Nodes")); return getNodes(mConfig.getJSONArray("Nodes"));
} catch (JSONException e) { } catch (JSONException e) {
Log.w(TAG, "Failed to read nodes", e); Log.w(TAG, "Failed to read nodes", e);
return new ArrayList<Node>(); return null;
} }
} }
@ -432,7 +425,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
*/ */
private List<Node> getNodes(JSONArray nodes) throws JSONException { private List<Node> getNodes(JSONArray nodes) throws JSONException {
List<Node> ret; List<Node> ret;
ret = new ArrayList<Node>(nodes.length()); ret = new ArrayList<>(nodes.length());
for (int i = 0; i < nodes.length(); i++) { for (int i = 0; i < nodes.length(); i++) {
JSONObject json = nodes.getJSONObject(i); JSONObject json = nodes.getJSONObject(i);
Node n = new Node(); Node n = new Node();
@ -453,12 +446,12 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
*/ */
public List<Repo> getRepos() { public List<Repo> getRepos() {
if (mConfig == null) if (mConfig == null)
return new ArrayList<Repo>(); return new ArrayList<>();
List<Repo> ret = null; List<Repo> ret;
try { try {
JSONArray repos = mConfig.getJSONArray("Repositories"); JSONArray repos = mConfig.getJSONArray("Repositories");
ret = new ArrayList<Repo>(repos.length()); ret = new ArrayList<>(repos.length());
for (int i = 0; i < repos.length(); i++) { for (int i = 0; i < repos.length(); i++) {
JSONObject json = repos.getJSONObject(i); JSONObject json = repos.getJSONObject(i);
Repo r = new Repo(); Repo r = new Repo();
@ -482,6 +475,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
} }
} catch (JSONException e) { } catch (JSONException e) {
Log.w(TAG, "Failed to read nodes", e); Log.w(TAG, "Failed to read nodes", e);
return null;
} }
return ret; return ret;
} }
@ -661,7 +655,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
*/ */
public void editNode(final Node node, public void editNode(final Node node,
final OnNodeIdNormalizedListener listener) { final OnNodeIdNormalizedListener listener) {
mSyncthingService.getApi().normalizeNodeId(node.NodeID, normalizeNodeId(node.NodeID,
new RestApi.OnNodeIdNormalizedListener() { new RestApi.OnNodeIdNormalizedListener() {
@Override @Override
public void onNodeIdNormalized(String normalizedId, String error) { public void onNodeIdNormalized(String normalizedId, String error) {
@ -696,7 +690,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
n.put("NodeID", node.NodeID); n.put("NodeID", node.NodeID);
n.put("Name", node.Name); n.put("Name", node.Name);
n.put("Addresses", listToJson(node.Addresses.split(" "))); n.put("Addresses", listToJson(node.Addresses.split(" ")));
configUpdated(mSyncthingService); configUpdated(mContext);
} catch (JSONException e) { } catch (JSONException e) {
Log.w(TAG, "Failed to read nodes", e); Log.w(TAG, "Failed to read nodes", e);
} }
@ -708,7 +702,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
/** /**
* Deletes the given node from syncthing. * Deletes the given node from syncthing.
*/ */
public void deleteNode(Node node, Activity activity) { public boolean deleteNode(Node node, Context context) {
try { try {
JSONArray nodes = mConfig.getJSONArray("Nodes"); JSONArray nodes = mConfig.getJSONArray("Nodes");
@ -720,16 +714,18 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
break; break;
} }
} }
configUpdated(activity); configUpdated(context);
} catch (JSONException e) { } catch (JSONException e) {
Log.w(TAG, "Failed to edit repo", e); Log.w(TAG, "Failed to edit repo", e);
return false;
} }
return true;
} }
/** /**
* Updates or creates the given node. * Updates or creates the given node.
*/ */
public void editRepo(Repo repo, boolean create, Context context) { public boolean editRepo(Repo repo, boolean create, Context context) {
try { try {
JSONArray repos = mConfig.getJSONArray("Repositories"); JSONArray repos = mConfig.getJSONArray("Repositories");
JSONObject r = null; JSONObject r = null;
@ -769,13 +765,15 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
configUpdated(context); configUpdated(context);
} catch (JSONException e) { } catch (JSONException e) {
Log.w(TAG, "Failed to edit repo " + repo.ID + " at " + repo.Directory, e); Log.w(TAG, "Failed to edit repo " + repo.ID + " at " + repo.Directory, e);
return false;
} }
return true;
} }
/** /**
* Deletes the given repository from syncthing. * Deletes the given repository from syncthing.
*/ */
public void deleteRepo(Repo repo, Activity activity) { public boolean deleteRepo(Repo repo, Context context) {
try { try {
JSONArray repos = mConfig.getJSONArray("Repositories"); JSONArray repos = mConfig.getJSONArray("Repositories");
@ -787,10 +785,12 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
break; break;
} }
} }
configUpdated(activity); configUpdated(context);
} catch (JSONException e) { } catch (JSONException e) {
Log.w(TAG, "Failed to edit repo", e); Log.w(TAG, "Failed to edit repo", e);
return false;
} }
return true;
} }
/** /**
@ -844,13 +844,13 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
/** /**
* Shares the given node id via Intent. Must be called from an Activity. * Shares the given node id via Intent. Must be called from an Activity.
*/ */
public static void shareNodeId(Activity activity, String id) { public static void shareNodeId(Context context, String id) {
Intent shareIntent = new Intent(); Intent shareIntent = new Intent();
shareIntent.setAction(Intent.ACTION_SEND); shareIntent.setAction(Intent.ACTION_SEND);
shareIntent.setType("text/plain"); shareIntent.setType("text/plain");
shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, id); shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, id);
activity.startActivity(Intent.createChooser( context.startActivity(Intent.createChooser(
shareIntent, activity.getString(R.string.send_node_id_to))); shareIntent, context.getString(R.string.send_node_id_to)));
} }
/** /**
@ -863,15 +863,15 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
int sdk = android.os.Build.VERSION.SDK_INT; int sdk = android.os.Build.VERSION.SDK_INT;
if (sdk < android.os.Build.VERSION_CODES.HONEYCOMB) { if (sdk < android.os.Build.VERSION_CODES.HONEYCOMB) {
android.text.ClipboardManager clipboard = (android.text.ClipboardManager) android.text.ClipboardManager clipboard = (android.text.ClipboardManager)
mSyncthingService.getSystemService(Context.CLIPBOARD_SERVICE); mContext.getSystemService(Context.CLIPBOARD_SERVICE);
clipboard.setText(id); clipboard.setText(id);
} else { } else {
ClipboardManager clipboard = (ClipboardManager) ClipboardManager clipboard = (ClipboardManager)
mSyncthingService.getSystemService(Context.CLIPBOARD_SERVICE); mContext.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(mSyncthingService.getString(R.string.node_id), id); ClipData clip = ClipData.newPlainText(mContext.getString(R.string.node_id), id);
clipboard.setPrimaryClip(clip); clipboard.setPrimaryClip(clip);
} }
Toast.makeText(mSyncthingService, R.string.node_id_copied_to_clipboard, Toast.LENGTH_SHORT) Toast.makeText(mContext, R.string.node_id_copied_to_clipboard, Toast.LENGTH_SHORT)
.show(); .show();
} }

View File

@ -54,12 +54,6 @@ public class SyncthingService extends Service {
*/ */
public static final int GUI_UPDATE_INTERVAL = 1000; public static final int GUI_UPDATE_INTERVAL = 1000;
/**
* Interval in ms, at which connections to the web gui are performed on first start
* to find out if it's online.
*/
private static final long WEB_GUI_POLL_INTERVAL = 100;
/** /**
* Name of the public key file in the data directory. * Name of the public key file in the data directory.
*/ */
@ -73,7 +67,9 @@ public class SyncthingService extends Service {
/** /**
* Path to the native, integrated syncthing binary, relative to the data folder * Path to the native, integrated syncthing binary, relative to the data folder
*/ */
private static final String BINARY_NAME = "lib/libsyncthing.so"; public static final String BINARY_NAME = "lib/libsyncthing.so";
private ConfigXml mConfig;
private RestApi mApi; private RestApi mApi;
@ -113,7 +109,7 @@ public class SyncthingService extends Service {
/** /**
* True if a stop was requested while syncthing is starting, in that case, perform stop in * True if a stop was requested while syncthing is starting, in that case, perform stop in
* {@link PollWebGuiAvailableTask}. * {@link PollWebGuiAvailableTaskImpl}.
*/ */
private boolean mStopScheduled = false; private boolean mStopScheduled = false;
@ -121,7 +117,7 @@ public class SyncthingService extends Service {
/** /**
* Handles intents, either {@link #ACTION_RESTART}, or intents having * Handles intents, either {@link #ACTION_RESTART}, or intents having
* {@link DeviceStateHolder.EXTRA_HAS_WIFI} or {@link DeviceStateHolder.EXTRA_IS_CHARGING} * {@link DeviceStateHolder#EXTRA_HAS_WIFI} or {@link DeviceStateHolder#EXTRA_IS_CHARGING}
* (which are handled by {@link DeviceStateHolder}. * (which are handled by {@link DeviceStateHolder}.
*/ */
@Override @Override
@ -131,15 +127,10 @@ public class SyncthingService extends Service {
} else if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { } else if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
new PostTask() { new PostTask() {
@Override @Override
protected void onPostExecute(Void aVoid) { protected void onPostExecute(Boolean aBoolean) {
ConfigXml config = new ConfigXml(SyncthingService.this); new StartupTask().execute();
mApi = new RestApi(SyncthingService.this,
config.getWebGuiUrl(), config.getApiKey());
mCurrentState = State.STARTING;
registerOnWebGuiAvailableListener(mApi);
new PollWebGuiAvailableTask().execute();
} }
}.execute(mApi.getUrl(), PostTask.URI_RESTART, mApi.getApiKey()); }.execute(mConfig.getWebGuiUrl(), PostTask.URI_RESTART, mConfig.getApiKey());
} else if (mCurrentState != State.INIT) { } else if (mCurrentState != State.INIT) {
mDeviceStateHolder.update(intent); mDeviceStateHolder.update(intent);
updateState(); updateState();
@ -169,7 +160,7 @@ public class SyncthingService extends Service {
mCurrentState = State.STARTING; mCurrentState = State.STARTING;
registerOnWebGuiAvailableListener(mApi); registerOnWebGuiAvailableListener(mApi);
new PollWebGuiAvailableTask().execute(); new PollWebGuiAvailableTaskImpl().execute(mConfig.getWebGuiUrl());
new Thread(new SyncthingRunnable( new Thread(new SyncthingRunnable(
this, getApplicationInfo().dataDir + "/" + BINARY_NAME)).start(); this, getApplicationInfo().dataDir + "/" + BINARY_NAME)).start();
} }
@ -190,53 +181,6 @@ public class SyncthingService extends Service {
onApiChange(); onApiChange();
} }
/**
* Polls SYNCTHING_URL until it returns HTTP status OK, then calls all listeners
* in mOnWebGuiAvailableListeners and clears it.
*/
private class PollWebGuiAvailableTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... voids) {
int status = 0;
HttpClient httpclient = new DefaultHttpClient();
HttpHead head = new HttpHead(mApi.getUrl());
do {
try {
Thread.sleep(WEB_GUI_POLL_INTERVAL);
HttpResponse response = httpclient.execute(head);
// NOTE: status is not really needed, as HttpHostConnectException is thrown
// earlier.
status = response.getStatusLine().getStatusCode();
} catch (HttpHostConnectException e) {
// We catch this in every call, as long as the service is not online,
// so we ignore and continue.
} catch (IOException e) {
Log.w(TAG, "Failed to poll for web interface", e);
} catch (InterruptedException e) {
Log.w(TAG, "Failed to poll for web interface", e);
}
} while (status != HttpStatus.SC_OK);
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
if (mStopScheduled) {
mCurrentState = State.DISABLED;
onApiChange();
mApi.shutdown();
mStopScheduled = false;
return;
}
Log.i(TAG, "Web GUI has come online at " + mApi.getUrl());
mCurrentState = State.ACTIVE;
for (OnWebGuiAvailableListener listener : mOnWebGuiAvailableListeners) {
listener.onWebGuiAvailable();
}
mOnWebGuiAvailableListeners.clear();
}
}
/** /**
* Move config file, keys, and index files to "official" folder * Move config file, keys, and index files to "official" folder
* <p/> * <p/>
@ -299,22 +243,29 @@ public class SyncthingService extends Service {
@Override @Override
protected Pair<String, String> doInBackground(Void... voids) { protected Pair<String, String> doInBackground(Void... voids) {
moveConfigFiles(); moveConfigFiles();
ConfigXml config = new ConfigXml(SyncthingService.this); mConfig = new ConfigXml(SyncthingService.this);
config.updateIfNeeded(); mConfig.updateIfNeeded();
if (isFirstStart()) { if (isFirstStart()) {
Log.i(TAG, "App started for the first time. " + Log.i(TAG, "App started for the first time. " +
"Copying default config, keys will be generated automatically"); "Copying default config, keys will be generated automatically");
config.createCameraRepo(); mConfig.createCameraRepo();
} }
return new Pair<>(config.getWebGuiUrl(), config.getApiKey()); return new Pair<>(mConfig.getWebGuiUrl(), mConfig.getApiKey());
} }
@Override @Override
protected void onPostExecute(Pair<String, String> urlAndKey) { protected void onPostExecute(Pair<String, String> urlAndKey) {
mApi = new RestApi(SyncthingService.this, urlAndKey.first, urlAndKey.second); mApi = new RestApi(SyncthingService.this, urlAndKey.first, urlAndKey.second,
Log.i(TAG, "Web GUI will be available at " + mApi.getUrl()); new RestApi.OnApiAvailableListener() {
@Override
public void onApiAvailable() {
onApiChange();
}
});
registerOnWebGuiAvailableListener(mApi);
Log.i(TAG, "Web GUI will be available at " + mConfig.getWebGuiUrl());
// HACK: Make sure there is no syncthing binary left running from an improper // HACK: Make sure there is no syncthing binary left running from an improper
// shutdown (eg Play Store updateIfNeeded). // shutdown (eg Play Store updateIfNeeded).
@ -379,12 +330,31 @@ public class SyncthingService extends Service {
mOnApiChangeListeners.add(new WeakReference<OnApiChangeListener>(listener)); mOnApiChangeListeners.add(new WeakReference<OnApiChangeListener>(listener));
} }
private class PollWebGuiAvailableTaskImpl extends PollWebGuiAvailableTask {
@Override
protected void onPostExecute(Void aVoid) {
if (mStopScheduled) {
mCurrentState = State.DISABLED;
onApiChange();
mApi.shutdown();
mStopScheduled = false;
return;
}
Log.i(TAG, "Web GUI has come online at " + mConfig.getWebGuiUrl());
mCurrentState = State.ACTIVE;
for (OnWebGuiAvailableListener listener : mOnWebGuiAvailableListeners) {
listener.onWebGuiAvailable();
}
mOnWebGuiAvailableListeners.clear();
}
}
/** /**
* Called to notifiy listeners of an API change. * Called to notifiy listeners of an API change.
* <p/> *
* Must only be called from SyncthingService or {@link RestApi}. * Must only be called from SyncthingService or {@link RestApi}.
*/ */
public void onApiChange() { private void onApiChange() {
for (Iterator<WeakReference<OnApiChangeListener>> i = mOnApiChangeListeners.iterator(); for (Iterator<WeakReference<OnApiChangeListener>> i = mOnApiChangeListeners.iterator();
i.hasNext(); ) { i.hasNext(); ) {
WeakReference<OnApiChangeListener> listener = i.next(); WeakReference<OnApiChangeListener> listener = i.next();
@ -427,4 +397,8 @@ public class SyncthingService extends Service {
.setCancelable(false); .setCancelable(false);
} }
public String getWebGuiUrl() {
return mConfig.getWebGuiUrl();
}
} }

View File

@ -42,26 +42,28 @@ public class NodesAdapter extends ArrayAdapter<RestApi.Node>
TextView download = (TextView) convertView.findViewById(R.id.download); TextView download = (TextView) convertView.findViewById(R.id.download);
TextView upload = (TextView) convertView.findViewById(R.id.upload); TextView upload = (TextView) convertView.findViewById(R.id.upload);
name.setText(getItem(position).Name); String nodeId = getItem(position).NodeID;
final String nodeId = getItem(position).NodeID;
RestApi.Connection conn = mConnections.get(nodeId); RestApi.Connection conn = mConnections.get(nodeId);
Resources res = getContext().getResources();
name.setText(getItem(position).Name);
Resources r = getContext().getResources();
if (conn != null) { if (conn != null) {
if (conn.Completion == 100) { if (conn.Completion == 100) {
status.setText(res.getString(R.string.node_up_to_date)); status.setText(r.getString(R.string.node_up_to_date));
status.setTextColor(res.getColor(R.color.text_green)); status.setTextColor(r.getColor(R.color.text_green));
} else { }
status.setText(res.getString(R.string.node_syncing, conn.Completion)); else {
status.setTextColor(res.getColor(R.color.text_blue)); status.setText(r.getString(R.string.node_syncing, conn.Completion));
status.setTextColor(r.getColor(R.color.text_blue));
} }
download.setText(RestApi.readableTransferRate(getContext(), conn.InBits)); download.setText(RestApi.readableTransferRate(getContext(), conn.InBits));
upload.setText(RestApi.readableTransferRate(getContext(), conn.OutBits)); upload.setText(RestApi.readableTransferRate(getContext(), conn.OutBits));
} else { }
download.setText("0 " + res.getStringArray(R.array.transfer_rate_units)[0]); else {
upload.setText("0 " + res.getStringArray(R.array.transfer_rate_units)[0]); download.setText("0 " + r.getStringArray(R.array.transfer_rate_units)[0]);
status.setText(res.getString(R.string.node_disconnected)); upload.setText("0 " + r.getStringArray(R.array.transfer_rate_units)[0]);
status.setTextColor(res.getColor(R.color.text_red)); status.setText(r.getString(R.string.node_disconnected));
status.setTextColor(r.getColor(R.color.text_red));
} }
return convertView; return convertView;

View File

@ -35,17 +35,17 @@ public class ReposAdapter extends ArrayAdapter<RestApi.Repo>
} }
TextView id = (TextView) convertView.findViewById(R.id.id); TextView id = (TextView) convertView.findViewById(R.id.id);
TextView state = (TextView) convertView.findViewById(R.id.state); TextView state = (TextView) convertView.findViewById(R.id.state);
TextView folder = (TextView) convertView.findViewById(R.id.folder); TextView directory = (TextView) convertView.findViewById(R.id.directory);
TextView items = (TextView) convertView.findViewById(R.id.items); TextView items = (TextView) convertView.findViewById(R.id.items);
TextView size = (TextView) convertView.findViewById(R.id.size); TextView size = (TextView) convertView.findViewById(R.id.size);
TextView invalid = (TextView) convertView.findViewById(R.id.invalid); TextView invalid = (TextView) convertView.findViewById(R.id.invalid);
id.setText(getItem(position).ID); RestApi.Repo repo = getItem(position);
RestApi.Model model = mModels.get(repo.ID);
id.setText(repo.ID);
state.setTextColor(getContext().getResources().getColor(R.color.text_green)); state.setTextColor(getContext().getResources().getColor(R.color.text_green));
folder.setText((getItem(position).Directory)); directory.setText((repo.Directory));
RestApi.Model model = mModels.get(getItem(position).ID);
if (model != null) { if (model != null) {
state.setText(getContext().getString(R.string.repo_progress_format, model.state, state.setText(getContext().getString(R.string.repo_progress_format, model.state,
(model.globalBytes <= 0) (model.globalBytes <= 0)
@ -56,10 +56,13 @@ public class ReposAdapter extends ArrayAdapter<RestApi.Repo>
.getString(R.string.files, model.localFiles, model.globalFiles)); .getString(R.string.files, model.localFiles, model.globalFiles));
size.setText(RestApi.readableFileSize(getContext(), model.localBytes) + " / " + size.setText(RestApi.readableFileSize(getContext(), model.localBytes) + " / " +
RestApi.readableFileSize(getContext(), model.globalBytes)); RestApi.readableFileSize(getContext(), model.globalBytes));
invalid.setText(model.invalid); if (repo.Invalid.equals("")) {
invalid.setVisibility((model.invalid.equals("")) ? View.GONE : View.VISIBLE); invalid.setText(model.invalid);
invalid.setVisibility((model.invalid.equals("")) ? View.GONE : View.VISIBLE);
}
} else { } else {
invalid.setVisibility(View.GONE); invalid.setText(repo.Invalid);
invalid.setVisibility((repo.Invalid.equals("")) ? View.GONE : View.VISIBLE);
} }
return convertView; return convertView;
@ -91,4 +94,5 @@ public class ReposAdapter extends ArrayAdapter<RestApi.Repo>
mModels.put(repoId, model); mModels.put(repoId, model);
notifyDataSetChanged(); notifyDataSetChanged();
} }
} }

View File

@ -24,7 +24,7 @@
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<TextView <TextView
android:id="@+id/folder" android:id="@+id/directory"
android:layout_below="@id/state" android:layout_below="@id/state"
android:textAppearance="?android:attr/textAppearanceSmall" android:textAppearance="?android:attr/textAppearanceSmall"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -33,7 +33,7 @@
<TextView <TextView
android:id="@+id/items" android:id="@+id/items"
android:layout_below="@id/folder" android:layout_below="@id/directory"
android:textAppearance="?android:attr/textAppearanceSmall" android:textAppearance="?android:attr/textAppearanceSmall"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />