package com.nutomic.syncthingandroid.service;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Environment;
import android.os.PowerManager;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.text.TextUtils;
import android.util.Log;
import com.google.common.base.Charsets;
import com.google.common.io.Files;
import com.nutomic.syncthingandroid.R;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import eu.chainfire.libsuperuser.Shell;
/**
* Runs the syncthing binary from command line, and prints its output to logcat.
*
* @see <a href="http://docs.syncthing.net/users/syncthing.html">Command Line Docs</a>
*/
public class SyncthingRunnable implements Runnable {
private static final String TAG = "SyncthingRunnable";
private static final String TAG_NATIVE = "SyncthingNativeCode";
private static final String TAG_NICE = "SyncthingRunnableIoNice";
private static final String TAG_KILL = "SyncthingRunnableKill";
public static final String UNIT_TEST_PATH = "was running";
private static final String BINARY_NAME = "libsyncthing.so";
private static final int LOG_FILE_MAX_LINES = 10;
private static final int NOTIFICATION_ID_CRASH = 9;
private static final AtomicReference<Process> mSyncthing = new AtomicReference<>();
private final Context mContext;
private final String mSyncthingBinary;
private String[] mCommand;
private String mErrorLog;
private final File mLogFile;
private final SharedPreferences mPreferences;
public enum Command {
generate, // Generate keys, a config file and immediately exit.
main, // Run the main Syncthing application.
reset, // Reset Syncthing's indexes
}
/**
* Constructs instance.
*
* @param command Which type of Syncthing command to execute.
*/
public SyncthingRunnable(Context context, Command command) {
mContext = context;
mSyncthingBinary = mContext.getApplicationInfo().nativeLibraryDir + "/" + BINARY_NAME;
mLogFile = new File(mContext.getExternalFilesDir(null), "syncthing.log");
mPreferences = PreferenceManager.getDefaultSharedPreferences(mContext);
switch (command) {
case generate:
mCommand = new String[]{ mSyncthingBinary, "-generate", mContext.getFilesDir().toString() };
break;
case main:
mCommand = new String[]{ mSyncthingBinary, "-home", mContext.getFilesDir().toString(), "-no-browser" };
break;
case reset:
mCommand = new String[]{ mSyncthingBinary, "-home", mContext.getFilesDir().toString(), "-reset" };
break;
default:
Log.w(TAG, "Unknown command option");
}
}
/**
* Constructs instance.
*
* @param manualCommand The exact command to be executed on the shell. Used for tests only.
*/
public SyncthingRunnable(Context context, String[] manualCommand) {
mContext = context;
mSyncthingBinary = mContext.getApplicationInfo().nativeLibraryDir + "/" + BINARY_NAME;
mCommand = manualCommand;
mLogFile = new File(mContext.getExternalFilesDir(null), "syncthing.log");
mPreferences = PreferenceManager.getDefaultSharedPreferences(mContext);
}
@Override
public void run() {
trimLogFile();
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(mContext);
int ret;
// Make sure Syncthing is executable
try {
ProcessBuilder pb = new ProcessBuilder("chmod", "500", mSyncthingBinary);
Process p = pb.start();
p.waitFor();
} catch (IOException|InterruptedException e) {
Log.w(TAG, "Failed to chmod Syncthing", e);
}
// Loop Syncthing
Process process = null;
// Potential fix for #498, keep the CPU running while native binary is running
PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
PowerManager.WakeLock wakeLock = useWakeLock()
? pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG)
: null;
try {
if (wakeLock != null)
wakeLock.acquire();
ProcessBuilder pb = (useRoot())
? new ProcessBuilder("su", "-c", TextUtils.join(" ", mCommand))
: new ProcessBuilder(mCommand);
Map<String, String> env = pb.environment();
// Set home directory to data folder for web GUI folder picker.
env.put("HOME", Environment.getExternalStorageDirectory().getAbsolutePath());
env.put("STTRACE", sp.getString("sttrace", ""));
File externalFilesDir = mContext.getExternalFilesDir(null);
if (externalFilesDir != null)
env.put("STGUIASSETS", externalFilesDir.getAbsolutePath() + "/gui");
env.put("STNORESTART", "1");
env.put("STNOUPGRADE", "1");
if (sp.getBoolean("use_tor", false)) {
env.put("all_proxy", "socks5://localhost:9050");
env.put("ALL_PROXY_NO_FALLBACK", "1");
}
if (sp.getBoolean("use_legacy_hashing", false))
env.put("STHASHING", "standard");
process = pb.start();
mSyncthing.set(process);
mErrorLog = "";
Thread lInfo = log(process.getInputStream(), Log.INFO, true);
Thread lWarn = log(process.getErrorStream(), Log.WARN, true);
niceSyncthing();
ret = process.waitFor();
mSyncthing.set(null);
lInfo.join();
lWarn.join();
switch (ret) {
case 0:
case 137:
// Syncthing was shut down (via API or SIGKILL), do nothing.
break;
case 1:
// Syncthing is already running, kill it and try again.
killSyncthing();
//fallthrough
case 3:
// Restart was requested via Rest API call.
Log.i(TAG, "Restarting syncthing");
mContext.startService(new Intent(mContext, SyncthingService.class)
.setAction(SyncthingService.ACTION_RESTART));
break;
default:
Log.w(TAG, "Syncthing has crashed (exit code " + ret + ")");
if (mPreferences.getBoolean("notify_crashes", false)) {
// Show notification to inform user about crash.
Intent intent = new Intent();
intent.setAction(android.content.Intent.ACTION_VIEW);
intent.setDataAndType(Uri.fromFile(mLogFile), "text/plain");
Notification n = new NotificationCompat.Builder(mContext)
.setContentTitle(mContext.getString(R.string.notification_crash_title))
.setContentText(mContext.getString(R.string.notification_crash_text))
.setSmallIcon(R.drawable.ic_stat_notify)
.setContentIntent(PendingIntent.getActivity(mContext, 0, intent, 0))
.setAutoCancel(true)
.build();
NotificationManager nm = (NotificationManager)
mContext.getSystemService(Context.NOTIFICATION_SERVICE);
nm.notify(NOTIFICATION_ID_CRASH, n);
}
}
} catch (IOException | InterruptedException e) {
Log.e(TAG, "Failed to execute syncthing binary or read output", e);
} finally {
if (wakeLock != null)
wakeLock.release();
if (process != null)
process.destroy();
}
}
/**
* Returns true if root is available and enabled in settings.
*/
private boolean useRoot() {
return mPreferences.getBoolean(SyncthingService.PREF_USE_ROOT, false) && Shell.SU.available();
}
/**
* Returns true if the experimental setting for using wake locks has been enabled in settings.
*/
private boolean useWakeLock() {
return mPreferences.getBoolean(SyncthingService.PREF_USE_WAKE_LOCK, false);
}
/**
* Look for a running libsyncthing.so process and nice its IO.
*/
private void niceSyncthing() {
new Thread() {
public void run() {
Process nice = null;
DataOutputStream niceOut = null;
int ret = 1;
try {
Thread.sleep(1000); // Wait a second before getting the pid
nice = Runtime.getRuntime().exec((useRoot()) ? "su" : "sh");
niceOut = new DataOutputStream(nice.getOutputStream());
niceOut.writeBytes("set `ps | grep libsyncthing.so`\n");
niceOut.writeBytes("ionice $2 be 7\n"); // best-effort, low priority
niceOut.writeBytes("exit\n");
log(nice.getErrorStream(), Log.WARN, false);
niceOut.flush();
ret = nice.waitFor();
Log.i(TAG_NICE, "ionice performed on libsyncthing.so");
} catch (IOException | InterruptedException e) {
Log.e(TAG_NICE, "Failed to execute ionice binary", e);
} finally {
try {
if (niceOut != null) {
niceOut.close();
}
} catch (IOException e) {
Log.w(TAG_NICE, "Failed to close shell stream", e);
}
if (nice != null) {
nice.destroy();
}
if (ret != 0) {
Log.e(TAG_NICE, "Failed to set ionice " + Integer.toString(ret));
}
}
}
}.start();
}
/**
* Look for running libsyncthing.so processes and kill them.
* Try a SIGINT first, then try again with SIGKILL.
*/
public void killSyncthing() {
for (int i = 0; i < 2; i++) {
Process ps = null;
DataOutputStream psOut = null;
try {
ps = Runtime.getRuntime().exec((useRoot()) ? "su" : "sh");
psOut = new DataOutputStream(ps.getOutputStream());
psOut.writeBytes("ps | grep libsyncthing.so\n");
psOut.writeBytes("exit\n");
psOut.flush();
ps.waitFor();
InputStreamReader isr = new InputStreamReader(ps.getInputStream(), "UTF-8");
BufferedReader br = new BufferedReader(isr);
String id;
while ((id = br.readLine()) != null) {
id = id.trim().split("\\s+")[1];
killProcessId(id, i > 0);
}
} catch (IOException | InterruptedException e) {
Log.w(TAG_KILL, "Failed list Syncthing processes", e);
} finally {
try {
if (psOut != null)
psOut.close();
} catch (IOException e) {
Log.w(TAG_KILL, "Failed close the psOut stream", e);
}
if (ps != null) {
ps.destroy();
}
}
}
}
/**
* Kill a given process ID
*
* @param force Whether to use a SIGKILL.
*/
private void killProcessId(String id, boolean force) {
Process kill = null;
DataOutputStream killOut = null;
try {
kill = Runtime.getRuntime().exec((useRoot()) ? "su" : "sh");
killOut = new DataOutputStream(kill.getOutputStream());
if (!force) {
killOut.writeBytes("kill -SIGINT " + id + "\n");
killOut.writeBytes("sleep 1\n");
} else {
killOut.writeBytes("sleep 3\n");
killOut.writeBytes("kill -SIGKILL " + id + "\n");
}
killOut.writeBytes("exit\n");
killOut.flush();
kill.waitFor();
Log.i(TAG_KILL, "Killed Syncthing process "+id);
} catch (IOException | InterruptedException e) {
Log.w(TAG_KILL, "Failed to kill process id "+id, e);
} finally {
try {
if (killOut != null)
killOut.close();
} catch (IOException e) {
Log.w(TAG_KILL, "Failed close the killOut stream", e);}
if (kill != null) {
kill.destroy();
}
}
}
/**
* Logs the outputs of a stream to logcat and mNativeLog.
*
* @param is The stream to log.
* @param priority The priority level.
* @param saveLog True if the log should be stored to {@link #mErrorLog}.
*/
private Thread log(final InputStream is, final int priority, final boolean saveLog) {
Thread t = new Thread(() -> {
try {
InputStreamReader isr = new InputStreamReader(is, Charsets.UTF_8);
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
Log.println(priority, TAG_NATIVE, line);
if (saveLog) {
mErrorLog += line + "\n";
Files.append(line + "\n", mLogFile, Charsets.UTF_8);
}
}
} catch (IOException e) {
Log.w(TAG, "Failed to read Syncthing's command line output", e);
}
});
t.start();
return t;
}
/**
* Only keep last {@link #LOG_FILE_MAX_LINES} lines in log file, to avoid bloat.
*/
private void trimLogFile() {
try {
LineNumberReader lnr = new LineNumberReader(new FileReader(mLogFile));
lnr.skip(Long.MAX_VALUE);
int lineCount = lnr.getLineNumber();
lnr.close();
File tempFile = new File(mContext.getExternalFilesDir(null), "syncthing.log.tmp");
BufferedReader reader = new BufferedReader(new FileReader(mLogFile));
BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile));
String currentLine;
int startFrom = lineCount - LOG_FILE_MAX_LINES;
for (int i = 0; (currentLine = reader.readLine()) != null; i++) {
if (i > startFrom) {
writer.write(currentLine + "\n");
}
}
writer.close();
reader.close();
tempFile.renameTo(mLogFile);
} catch (IOException e) {
Log.w(TAG, "Failed to trim log file", e);
}
}
}