package com.nutomic.syncthingandroid.syncthing;
import android.annotation.TargetApi;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Environment;
import android.os.IBinder;
import android.os.PowerManager;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.util.Log;
import android.util.Pair;
import android.widget.Toast;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.activities.MainActivity;
import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask;
import com.nutomic.syncthingandroid.util.ConfigXml;
import com.nutomic.syncthingandroid.util.FolderObserver;
import com.android.PRNGFixes;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.channels.FileChannel;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;
/**
* Holds the native syncthing instance and provides an API to access it.
*/
public class SyncthingService extends Service implements
SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "SyncthingService";
/**
* Intent action to perform a Syncthing restart.
*/
public static final String ACTION_RESTART =
"com.nutomic.syncthingandroid.service.SyncthingService.RESTART";
/**
* Intent action to reset Syncthing's database.
*/
public static final String ACTION_RESET =
"com.nutomic.syncthingandroid.service.SyncthingService.RESET";
/**
* Interval in ms at which the GUI is updated (eg {@link com.nutomic.syncthingandroid.fragments.DrawerFragment}).
*/
public static final long GUI_UPDATE_INTERVAL = TimeUnit.SECONDS.toMillis(10);
/**
* name of the public key file in the data directory.
*/
public static final String PUBLIC_KEY_FILE = "cert.pem";
/**
* name of the private key file in the data directory.
*/
public static final String PRIVATE_KEY_FILE = "key.pem";
/**
* name of the public HTTPS CA file in the data directory.
*/
public static final String HTTPS_CERT_FILE = "https-cert.pem";
/**
* Directory where config is exported to and imported from.
*/
public static final File EXPORT_PATH =
new File(Environment.getExternalStorageDirectory(), "backups/syncthing");
/**
* path to the native, integrated syncthing binary, relative to the data folder
*/
public static final String BINARY_NAME = "lib/libsyncthing.so";
public static final String PREF_ALWAYS_RUN_IN_BACKGROUND = "always_run_in_background";
public static final String PREF_SYNC_ONLY_WIFI = "sync_only_wifi";
public static final String PREF_SYNC_ONLY_WIFI_SSIDS = "sync_only_wifi_ssids_set";
public static final String PREF_SYNC_ONLY_CHARGING = "sync_only_charging";
public static final String PREF_USE_ROOT = "use_root";
private static final String PREF_NOTIFICATION_TYPE = "notification_type";
public static final String PREF_USE_WAKE_LOCK = "wakelock_while_binary_running";
public static final String PREF_FOREGROUND_SERVICE = "run_as_foreground_service";
private static final int NOTIFICATION_ACTIVE = 1;
private ConfigXml mConfig;
private RestApi mApi;
private EventProcessor mEventProcessor;
private final LinkedList<FolderObserver> mObservers = new LinkedList<>();
private final SyncthingServiceBinder mBinder = new SyncthingServiceBinder(this);
/**
* Callback for when the Syncthing web interface becomes first available after service start.
*/
public interface OnWebGuiAvailableListener {
public void onWebGuiAvailable();
}
private final HashSet<OnWebGuiAvailableListener> mOnWebGuiAvailableListeners =
new HashSet<>();
public interface OnApiChangeListener {
public void onApiChange(State currentState);
}
private final HashSet<OnApiChangeListener> mOnApiChangeListeners =
new HashSet<>();
private final BroadcastReceiver mPowerSaveModeChangedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
updateState();
}
};
/**
* INIT: Service is starting up and initializing.
* STARTING: Syncthing binary is starting (but the API is not yet ready).
* ACTIVE: Syncthing binary is up and running.
* DISABLED: Syncthing binary is stopped according to user preferences.
*/
public enum State {
INIT,
STARTING,
ACTIVE,
DISABLED,
ERROR
}
private State mCurrentState = State.INIT;
/**
* Object that can be locked upon when accessing mCurrentState
* Currently used to male onDestroy() and PollWebGuiAvailableTaskImpl.onPostExcecute() tread-safe
*/
private final Object stateLock = new Object();
/**
* True if a stop was requested while syncthing is starting, in that case, perform stop in
* {@link PollWebGuiAvailableTaskImpl}.
*/
private boolean mStopScheduled = false;
private DeviceStateHolder mDeviceStateHolder;
private SyncthingRunnable mRunnable;
/**
* Handles intents, either {@link #ACTION_RESTART}, or intents having
* {@link DeviceStateHolder#EXTRA_HAS_WIFI} or {@link DeviceStateHolder#EXTRA_IS_CHARGING}
* (which are handled by {@link DeviceStateHolder}.
*/
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null)
return START_STICKY;
if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
shutdown();
mCurrentState = State.INIT;
updateState();
} else if (ACTION_RESET.equals(intent.getAction())) {
shutdown();
new SyncthingRunnable(this, SyncthingRunnable.Command.reset).run();
mCurrentState = State.INIT;
updateState();
} else if (mCurrentState != State.INIT) {
mDeviceStateHolder.update(intent);
updateState();
}
return START_STICKY;
}
/**
* Checks according to preferences and charging/wifi state, whether syncthing should be enabled
* or not.
*
* Depending on the result, syncthing is started or stopped, and {@link #onApiChange()} is
* called.
*/
public void updateState() {
// Start syncthing.
if (mDeviceStateHolder.shouldRun()) {
if (mCurrentState == State.ACTIVE || mCurrentState == State.STARTING) {
mStopScheduled = false;
return;
}
// HACK: Make sure there is no syncthing binary left running from an improper
// shutdown (eg Play Store update).
// NOTE: This will log an exception if syncthing is not actually running.
shutdown();
Log.i(TAG, "Starting syncthing according to current state and preferences");
mConfig = null;
try {
mConfig = new ConfigXml(SyncthingService.this);
} catch (ConfigXml.OpenConfigException e) {
mCurrentState = State.ERROR;
Toast.makeText(this, R.string.config_create_failed, Toast.LENGTH_LONG).show();
}
if (mConfig != null) {
mCurrentState = State.STARTING;
if (mApi != null)
registerOnWebGuiAvailableListener(mApi);
if (mEventProcessor != null)
registerOnWebGuiAvailableListener(mEventProcessor);
new PollWebGuiAvailableTaskImpl(getWebGuiUrl(), getFilesDir() + "/" + HTTPS_CERT_FILE, mConfig.getApiKey())
.execute();
mRunnable = new SyncthingRunnable(this, SyncthingRunnable.Command.main);
new Thread(mRunnable).start();
updateNotification();
}
}
// Stop syncthing.
else {
if (mCurrentState == State.DISABLED)
return;
Log.i(TAG, "Stopping syncthing according to current state and preferences");
mCurrentState = State.DISABLED;
shutdown();
}
onApiChange();
}
/**
* Shows or hides the persistent notification based on running state and
* {@link #PREF_NOTIFICATION_TYPE}.
*/
private void updateNotification() {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
String type = sp.getString(PREF_NOTIFICATION_TYPE, "low_priority");
boolean foreground = sp.getBoolean(PREF_FOREGROUND_SERVICE, false);
if ("none".equals(type) && foreground) {
// foreground priority requires any notification
// so this ensures that we either have a "default" or "low_priority" notification,
// but not "none".
type = "low_priority";
}
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if ((mCurrentState == State.ACTIVE || mCurrentState == State.STARTING) &&
!type.equals("none")) {
Context appContext = getApplicationContext();
NotificationCompat.Builder builder = new NotificationCompat.Builder(appContext)
.setContentTitle(getString(R.string.syncthing_active))
.setSmallIcon(R.drawable.ic_stat_notify)
.setOngoing(true)
.setContentIntent(PendingIntent.getActivity(appContext, 0,
new Intent(appContext, MainActivity.class), 0));
if (type.equals("low_priority"))
builder.setPriority(NotificationCompat.PRIORITY_MIN);
if (foreground) {
builder.setContentText(getString(R.string.syncthing_active_foreground));
startForeground(NOTIFICATION_ACTIVE, builder.build());
} else {
stopForeground(false); // ensure no longer running with foreground priority
nm.notify(NOTIFICATION_ACTIVE, builder.build());
}
} else {
// ensure no longer running with foreground priority
stopForeground(false);
nm.cancel(NOTIFICATION_ACTIVE);
}
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (key.equals(PREF_NOTIFICATION_TYPE) || key.equals(PREF_FOREGROUND_SERVICE))
updateNotification();
else if (key.equals(PREF_SYNC_ONLY_CHARGING) || key.equals(PREF_SYNC_ONLY_WIFI)
|| key.equals(PREF_SYNC_ONLY_WIFI_SSIDS))
updateState();
}
/**
* Starts the native binary.
*/
@Override
@TargetApi(21)
public void onCreate() {
super.onCreate();
PRNGFixes.apply();
mDeviceStateHolder = new DeviceStateHolder(SyncthingService.this);
registerReceiver(mDeviceStateHolder, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB) {
registerReceiver(mPowerSaveModeChangedReceiver,
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
}
new StartupTask().execute();
PreferenceManager.getDefaultSharedPreferences(this)
.registerOnSharedPreferenceChangeListener(this);
}
/**
* Sets up the initial configuration, updates the config when coming from an old
* version, and reads syncthing URL and API key (these are passed internally as
* {@code Pair<String, String>}.
*/
private class StartupTask extends AsyncTask<Void, Void, Pair<URL, String>> {
@Override
protected Pair<URL, String> doInBackground(Void... voids) {
try {
mConfig = new ConfigXml(SyncthingService.this);
return new Pair<>(mConfig.getWebGuiUrl(), mConfig.getApiKey());
} catch (ConfigXml.OpenConfigException e) {
return null;
}
}
@Override
protected void onPostExecute(Pair<URL, String> urlAndKey) {
if (urlAndKey == null) {
Toast.makeText(SyncthingService.this, R.string.config_create_failed,
Toast.LENGTH_LONG).show();
mCurrentState = State.ERROR;
onApiChange();
return;
}
mApi = new RestApi(SyncthingService.this, urlAndKey.first, urlAndKey.second,
() -> {
mCurrentState = State.ACTIVE;
onApiChange();
new Thread(() -> {
for (RestApi.Folder r : mApi.getFolders()) {
try {
mObservers.add(new FolderObserver(mApi, r));
} catch (FolderObserver.FolderNotExistingException e) {
Log.w(TAG, "Failed to add observer for folder", e);
} catch (StackOverflowError e) {
Log.w(TAG, "Failed to add folder observer", e);
Toast.makeText(SyncthingService.this,
R.string.toast_folder_observer_stack_overflow,
Toast.LENGTH_LONG)
.show();
}
}
}).start();
}, SyncthingService.this::onApiChange);
mEventProcessor = new EventProcessor(SyncthingService.this, mApi);
registerOnWebGuiAvailableListener(mApi);
registerOnWebGuiAvailableListener(mEventProcessor);
Log.i(TAG, "Web GUI will be available at " + mConfig.getWebGuiUrl());
updateState();
}
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
/**
* Stops the native binary.
*
* The native binary crashes if stopped before it is fully active. In that case signal the
* stop request to PollWebGuiAvailableTaskImpl that is active in that situation and terminate
* the service there.
*/
@Override
public void onDestroy() {
synchronized (stateLock) {
if (mCurrentState == State.INIT || mCurrentState == State.STARTING) {
Log.i(TAG, "Delay shutting down service until initialisation of Syncthing finished");
mStopScheduled = true;
} else {
Log.i(TAG, "Shutting down service immediately");
shutdown();
}
}
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
sp.unregisterOnSharedPreferenceChangeListener(this);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB)
unregisterReceiver(mPowerSaveModeChangedReceiver);
}
private void shutdown() {
if (mEventProcessor != null)
mEventProcessor.shutdown();
if (mRunnable != null)
mRunnable.killSyncthing();
if (mApi != null)
mApi.shutdown();
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
stopForeground(false);
nm.cancel(NOTIFICATION_ACTIVE);
for (FolderObserver ro : mObservers) {
ro.stopWatching();
}
mObservers.clear();
}
/**
* Register a listener for the web gui becoming available..
*
* If the web gui is already available, listener will be called immediately.
* Listeners are unregistered automatically after being called.
*/
public void registerOnWebGuiAvailableListener(OnWebGuiAvailableListener listener) {
if (mCurrentState == State.ACTIVE) {
listener.onWebGuiAvailable();
} else {
mOnWebGuiAvailableListeners.add(listener);
}
}
/**
* Returns true if this service has not been started before (ie config.xml does not exist).
*
* This will return true until the public key file has been generated.
*/
public boolean isFirstStart() {
return !new File(getFilesDir(), PUBLIC_KEY_FILE).exists();
}
public RestApi getApi() {
return mApi;
}
/**
* Register a listener for the syncthing API state changing.
*
* The listener is called immediately with the current state, and again whenever the state
* changes. The call is always from the GUI thread.
*
* @see #unregisterOnApiChangeListener
*/
public void registerOnApiChangeListener(OnApiChangeListener listener) {
// Make sure we don't send an invalid state or syncthing might show a "disabled" message
// when it's just starting up.
listener.onApiChange(mCurrentState);
mOnApiChangeListeners.add(listener);
}
/**
* Unregisters a previously registered listener.
*
* @see #registerOnApiChangeListener
*/
public void unregisterOnApiChangeListener(OnApiChangeListener listener) {
mOnApiChangeListeners.remove(listener);
}
private class PollWebGuiAvailableTaskImpl extends PollWebGuiAvailableTask {
public PollWebGuiAvailableTaskImpl(URL url, String httpsCertPath, String apiKey) {
super(url, httpsCertPath, apiKey);
}
/**
* Wait for the web-gui of the native syncthing binary to come online.
*
* In case the binary is to be stopped, also be aware that another thread could request
* to stop the binary in the time while waiting for the GUI to become active. See the comment
* for SyncthingService.onDestroy for details.
*/
@Override
protected void onPostExecute(Void aVoid) {
synchronized (stateLock) {
if (mStopScheduled) {
mCurrentState = State.DISABLED;
onApiChange();
shutdown();
mStopScheduled = false;
stopSelf();
return;
}
}
Log.i(TAG, "Web GUI has come online at " + mConfig.getWebGuiUrl());
mCurrentState = State.STARTING;
onApiChange();
for (OnWebGuiAvailableListener listener : mOnWebGuiAvailableListeners) {
listener.onWebGuiAvailable();
}
mOnWebGuiAvailableListeners.clear();
}
}
/**
* Called to notifiy listeners of an API change.
*
* Must only be called from SyncthingService or {@link RestApi} on the main thread.
*/
private void onApiChange() {
for (Iterator<OnApiChangeListener> i = mOnApiChangeListeners.iterator();
i.hasNext(); ) {
OnApiChangeListener listener = i.next();
if (listener != null) {
listener.onApiChange(mCurrentState);
} else {
i.remove();
}
}
}
public URL getWebGuiUrl() {
return mConfig.getWebGuiUrl();
}
/**
* Returns the value of "always_run_in_background" preference.
*/
public static boolean alwaysRunInBackground(Context context) {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
return sp.getBoolean(PREF_ALWAYS_RUN_IN_BACKGROUND, false);
}
/**
* Exports the local config and keys to {@link #EXPORT_PATH}.
*/
public void exportConfig() {
EXPORT_PATH.mkdirs();
copyFile(new File(getFilesDir(), ConfigXml.CONFIG_FILE),
new File(EXPORT_PATH, ConfigXml.CONFIG_FILE));
copyFile(new File(getFilesDir(), PRIVATE_KEY_FILE),
new File(EXPORT_PATH, PRIVATE_KEY_FILE));
copyFile(new File(getFilesDir(), PUBLIC_KEY_FILE),
new File(EXPORT_PATH, PUBLIC_KEY_FILE));
}
/**
* Imports config and keys from {@link #EXPORT_PATH}.
*
* @return True if the import was successful, false otherwise (eg if files aren't found).
*/
public boolean importConfig() {
mCurrentState = State.DISABLED;
shutdown();
File config = new File(EXPORT_PATH, ConfigXml.CONFIG_FILE);
File privateKey = new File(EXPORT_PATH, PRIVATE_KEY_FILE);
File publicKey = new File(EXPORT_PATH, PUBLIC_KEY_FILE);
if (!config.exists() || !privateKey.exists() || !publicKey.exists())
return false;
copyFile(config, new File(getFilesDir(), ConfigXml.CONFIG_FILE));
copyFile(privateKey, new File(getFilesDir(), PRIVATE_KEY_FILE));
copyFile(publicKey, new File(getFilesDir(), PUBLIC_KEY_FILE));
mCurrentState = State.INIT;
updateState();
return true;
}
/**
* Copies files between different storage devices.
*/
private void copyFile(File source, File dest) {
FileChannel is = null;
FileChannel os = null;
try {
is = new FileInputStream(source).getChannel();
os = new FileOutputStream(dest).getChannel();
is.transferTo(0, is.size(), os);
} catch (IOException e) {
Log.w(TAG, "Failed to copy file", e);
} finally {
try {
if (is != null)
is.close();
if (os != null)
os.close();
} catch (IOException e) {
Log.w(TAG, "Failed to close stream", e);
}
}
}
}