Refactor SyncthingService (lifecycle), DeviceStateHolder, RestApi, multiple fixes (#1119)

This commit is contained in:
Catfriend1 2018-06-10 00:39:42 +02:00 committed by Audrius Butkevicius
parent 165c136bea
commit e9eef4332b
22 changed files with 625 additions and 381 deletions

View File

@ -213,8 +213,9 @@ public class DeviceActivity extends SyncthingActivity implements View.OnClickLis
@Override
public void onDestroy() {
super.onDestroy();
if (getService() != null) {
getService().unregisterOnApiChangeListener(this::onApiChange);
SyncthingService syncthingService = getService();
if (syncthingService != null) {
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
}
mIdView.removeTextChangedListener(mIdTextWatcher);
mNameView.removeTextChangedListener(mNameTextWatcher);
@ -252,7 +253,7 @@ public class DeviceActivity extends SyncthingActivity implements View.OnClickLis
}
private void onServiceConnected() {
getService().registerOnApiChangeListener(this::onApiChange);
getService().registerOnServiceStateChangeListener(this::onServiceStateChange);
}
/**
@ -271,7 +272,7 @@ public class DeviceActivity extends SyncthingActivity implements View.OnClickLis
}
}
private void onApiChange(SyncthingService.State currentState) {
private void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != ACTIVE) {
finish();
return;

View File

@ -16,7 +16,6 @@ import android.widget.Toast;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.service.SyncthingService;
import javax.inject.Inject;
@ -66,8 +65,6 @@ public class FirstStartActivity extends Activity implements Button.OnClickListen
mPreferences.edit().putBoolean("first_start", false).apply();
}
startService(new Intent(this, SyncthingService.class));
// In case start_into_web_gui option is enabled, start both activities so that back
// navigation works as expected.
Intent mainIntent = new Intent(this, MainActivity.class);

View File

@ -51,7 +51,7 @@ import static com.nutomic.syncthingandroid.service.SyncthingService.State.ACTIVE
* Shows folder details and allows changing them.
*/
public class FolderActivity extends SyncthingActivity
implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnApiChangeListener {
implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnServiceStateChangeListener {
public static final String EXTRA_IS_CREATE =
"com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE";
@ -234,8 +234,9 @@ public class FolderActivity extends SyncthingActivity
@Override
public void onDestroy() {
super.onDestroy();
if (getService() != null) {
getService().unregisterOnApiChangeListener(this);
SyncthingService syncthingService = getService();
if (syncthingService != null) {
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
}
mLabelView.removeTextChangedListener(mTextWatcher);
mIdView.removeTextChangedListener(mTextWatcher);
@ -270,11 +271,11 @@ public class FolderActivity extends SyncthingActivity
*/
@Override
public void onServiceConnected() {
getService().registerOnApiChangeListener(this);
getService().registerOnServiceStateChangeListener(this);
}
@Override
public void onApiChange(SyncthingService.State currentState) {
public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != ACTIVE) {
finish();
return;
@ -308,7 +309,7 @@ public class FolderActivity extends SyncthingActivity
// If the FolderActivity gets recreated after the VersioningDialogActivity is closed, then the result from the VersioningDialogActivity will be received before
// the mFolder variable has been recreated, so the versioning config will be stored in the mVersioning variable until the mFolder variable has been
// recreated in the onApiChange(). This has been observed to happen after the screen orientation has changed while the VersioningDialogActivity was open.
// recreated in the onServiceStateChange(). This has been observed to happen after the screen orientation has changed while the VersioningDialogActivity was open.
private void attemptToApplyVersioningConfig() {
if (mFolder != null && mVersioning != null){
mFolder.versioning = mVersioning;

View File

@ -44,7 +44,7 @@ import java.util.Iterator;
* Activity that allows selecting a directory in the local file system.
*/
public class FolderPickerActivity extends SyncthingActivity
implements AdapterView.OnItemClickListener, SyncthingService.OnApiChangeListener {
implements AdapterView.OnItemClickListener, SyncthingService.OnServiceStateChangeListener {
private static final String EXTRA_INITIAL_DIRECTORY =
"com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY";
@ -156,13 +156,16 @@ public class FolderPickerActivity extends SyncthingActivity
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
super.onServiceConnected(componentName, iBinder);
getService().registerOnApiChangeListener(this);
getService().registerOnServiceStateChangeListener(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
getService().unregisterOnApiChangeListener(this);
SyncthingService syncthingService = getService();
if (syncthingService != null) {
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
}
}
@Override
@ -323,7 +326,7 @@ public class FolderPickerActivity extends SyncthingActivity
}
@Override
public void onApiChange(SyncthingService.State currentState) {
public void onServiceStateChange(SyncthingService.State currentState) {
if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) {
setResult(Activity.RESULT_CANCELED);
finish();

View File

@ -66,7 +66,7 @@ import static java.lang.Math.min;
* {@link DrawerFragment} in the navigation drawer.
*/
public class MainActivity extends StateDialogActivity
implements SyncthingService.OnApiChangeListener {
implements SyncthingService.OnServiceStateChangeListener {
private static final String TAG = "MainActivity";
private static final String IS_SHOWING_RESTART_DIALOG = "RESTART_DIALOG_STATE";
@ -102,7 +102,7 @@ public class MainActivity extends StateDialogActivity
* Handles various dialogs based on current state.
*/
@Override
public void onApiChange(SyncthingService.State currentState) {
public void onServiceStateChange(SyncthingService.State currentState) {
switch (currentState) {
case STARTING:
break;
@ -254,6 +254,10 @@ public class MainActivity extends StateDialogActivity
mDrawerLayout.addDrawerListener(mDrawerToggle);
setOptimalDrawerWidth(findViewById(R.id.drawer));
// SyncthingService needs to be started from this activity as the user
// can directly launch this activity from the recent activity switcher.
startService(new Intent(this, SyncthingService.class));
onNewIntent(getIntent());
}
@ -271,19 +275,20 @@ public class MainActivity extends StateDialogActivity
@Override
public void onDestroy() {
super.onDestroy();
if (getService() != null) {
getService().unregisterOnApiChangeListener(this);
getService().unregisterOnApiChangeListener(mFolderListFragment);
getService().unregisterOnApiChangeListener(mDeviceListFragment);
SyncthingService mSyncthingService = getService();
if (mSyncthingService != null) {
mSyncthingService.unregisterOnServiceStateChangeListener(this);
mSyncthingService.unregisterOnServiceStateChangeListener(mFolderListFragment);
mSyncthingService.unregisterOnServiceStateChangeListener(mDeviceListFragment);
}
}
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
super.onServiceConnected(componentName, iBinder);
getService().registerOnApiChangeListener(this);
getService().registerOnApiChangeListener(mFolderListFragment);
getService().registerOnApiChangeListener(mDeviceListFragment);
getService().registerOnServiceStateChangeListener(this);
getService().registerOnServiceStateChangeListener(mFolderListFragment);
getService().registerOnServiceStateChangeListener(mDeviceListFragment);
}
/**
@ -430,15 +435,19 @@ public class MainActivity extends StateDialogActivity
return super.onKeyDown(keyCode, e);
}
/**
* Close drawer on back button press.
*/
@Override
public void onBackPressed() {
if (mDrawerLayout.isDrawerOpen(GravityCompat.START))
if (mDrawerLayout.isDrawerOpen(GravityCompat.START)) {
// Close drawer on back button press.
closeDrawer();
else
super.onBackPressed();
} else {
/**
* Leave MainActivity in its state as the home button was pressed.
* This will avoid waiting for the loading spinner when getting back
* and give changes to do UI updates based on EventProcessor in the future.
*/
moveTaskToBack(true);
}
}
/**

View File

@ -74,7 +74,7 @@ public class SettingsActivity extends SyncthingActivity {
public static class SettingsFragment extends PreferenceFragment
implements SyncthingActivity.OnServiceConnectedListener,
SyncthingService.OnApiChangeListener, Preference.OnPreferenceChangeListener,
SyncthingService.OnServiceStateChangeListener, Preference.OnPreferenceChangeListener,
Preference.OnPreferenceClickListener, SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "SettingsFragment";
@ -246,32 +246,26 @@ public class SettingsActivity extends SyncthingActivity {
@Override
public void onServiceConnected() {
Log.v(TAG, "onServiceConnected");
if (getActivity() == null)
return;
mSyncthingService = ((SyncthingActivity) getActivity()).getService();
mSyncthingService.registerOnApiChangeListener(this);
// Use callback to make sure getApi() doesn't return null.
mSyncthingService.registerOnWebGuiAvailableListener(() -> {
if (mSyncthingService.getApi().isConfigLoaded()) {
mGui = mSyncthingService.getApi().getGui();
mOptions = mSyncthingService.getApi().getOptions();
}
});
mSyncthingService.registerOnServiceStateChangeListener(this);
}
@Override
public void onApiChange(SyncthingService.State currentState) {
boolean syncthingActive = currentState == SyncthingService.State.ACTIVE;
boolean isSyncthingRunning = syncthingActive && mSyncthingService.getApi().isConfigLoaded();
public void onServiceStateChange(SyncthingService.State currentState) {
mApi = mSyncthingService.getApi();
boolean isSyncthingRunning = (mApi != null) &&
mApi.isConfigLoaded() &&
(currentState == SyncthingService.State.ACTIVE);
mCategorySyncthingOptions.setEnabled(isSyncthingRunning);
mCategoryBackup.setEnabled(isSyncthingRunning);
if (!isSyncthingRunning)
return;
mApi = mSyncthingService.getApi();
mSyncthingVersion.setSummary(mApi.getVersion());
mOptions = mApi.getOptions();
mGui = mApi.getGui();
@ -296,7 +290,7 @@ public class SettingsActivity extends SyncthingActivity {
public void onDestroy() {
mPreferences.unregisterOnSharedPreferenceChangeListener(this);
if (mSyncthingService != null) {
mSyncthingService.unregisterOnApiChangeListener(this);
mSyncthingService.unregisterOnServiceStateChangeListener(this);
}
super.onDestroy();
}
@ -382,9 +376,9 @@ public class SettingsActivity extends SyncthingActivity {
@Override
public void onStop() {
if (mRequireRestart) {
if (mSyncthingService.getCurrentState() != SyncthingService.State.DISABLED &&
mSyncthingService.getApi() != null) {
mSyncthingService.getApi().restart();
if (mSyncthingService != null && mApi != null &&
mSyncthingService.getCurrentState() != SyncthingService.State.DISABLED) {
mApi.restart();
mRequireRestart = false;
}
}
@ -405,7 +399,7 @@ public class SettingsActivity extends SyncthingActivity {
: R.string.always_run_in_background_disabled);
mSyncOnlyCharging.setEnabled(value);
mSyncOnlyWifi.setEnabled(value);
mSyncOnlyOnSSIDs.setEnabled(mSyncOnlyWifi.isChecked());
mSyncOnlyOnSSIDs.setEnabled(false);
// Uncheck items when disabled, so it is clear they have no effect.
if (!value) {
mSyncOnlyCharging.setChecked(false);

View File

@ -45,7 +45,7 @@ import java.util.Map;
* ownCloud Android {@see https://github.com/owncloud/android/blob/79664304fdb762b2e04f1ac505f50d0923ddd212/src/com/owncloud/android/utils/UriUtils.java#L193}
*/
public class ShareActivity extends StateDialogActivity
implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnApiChangeListener {
implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnServiceStateChangeListener {
private static final String TAG = "ShareActivity";
private static final String PREF_PREVIOUSLY_SELECTED_SYNCTHING_FOLDER = "previously_selected_syncthing_folder";
@ -57,7 +57,7 @@ public class ShareActivity extends StateDialogActivity
private Spinner mFoldersSpinner;
@Override
public void onApiChange(SyncthingService.State currentState) {
public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != SyncthingService.State.ACTIVE || getApi() == null)
return;
@ -85,7 +85,7 @@ public class ShareActivity extends StateDialogActivity
@Override
public void onServiceConnected() {
getService().registerOnApiChangeListener(this);
getService().registerOnServiceStateChangeListener(this);
}
@Override

View File

@ -12,6 +12,7 @@ import android.view.View;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.databinding.DialogLoadingBinding;
import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.service.SyncthingService.State;
import com.nutomic.syncthingandroid.util.Util;
import java.util.concurrent.TimeUnit;
@ -23,6 +24,7 @@ public abstract class StateDialogActivity extends SyncthingActivity {
private static final long SLOW_LOADING_TIME = TimeUnit.SECONDS.toMillis(30);
private State mServiceState = State.INIT;
private AlertDialog mLoadingDialog;
private AlertDialog mDisabledDialog;
private boolean mIsPaused = true;
@ -31,13 +33,20 @@ public abstract class StateDialogActivity extends SyncthingActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
registerOnServiceConnectedListener(() ->
getService().registerOnApiChangeListener(this::onApiChange));
getService().registerOnServiceStateChangeListener(this::onServiceStateChange));
}
@Override
protected void onResume() {
super.onResume();
mIsPaused = false;
switch (mServiceState) {
case DISABLED:
showDisabledDialog();
break;
default:
break;
}
}
@Override
@ -52,13 +61,14 @@ public abstract class StateDialogActivity extends SyncthingActivity {
protected void onDestroy() {
super.onDestroy();
if (getService() != null) {
getService().unregisterOnApiChangeListener(this::onApiChange);
getService().unregisterOnServiceStateChangeListener(this::onServiceStateChange);
}
dismissDisabledDialog();
}
private void onApiChange(SyncthingService.State currentState) {
switch (currentState) {
private void onServiceStateChange(SyncthingService.State currentState) {
mServiceState = currentState;
switch (mServiceState) {
case INIT: // fallthrough
case STARTING:
dismissDisabledDialog();
@ -69,24 +79,25 @@ public abstract class StateDialogActivity extends SyncthingActivity {
dismissLoadingDialog();
break;
case DISABLED:
dismissLoadingDialog();
if (!isFinishing()) {
if (!mIsPaused) {
showDisabledDialog();
}
break;
case ERROR: // fallthrough
default:
break;
}
}
private void showDisabledDialog() {
if (mIsPaused)
if (this.isFinishing() && (mDisabledDialog != null)) {
return;
}
mDisabledDialog = new AlertDialog.Builder(this)
.setTitle(R.string.syncthing_disabled_title)
.setMessage(R.string.syncthing_disabled_message)
.setPositiveButton(R.string.syncthing_disabled_change_settings,
(dialogInterface, i) -> {
finish();
startActivity(new Intent(this, SettingsActivity.class));
}
)
@ -123,7 +134,7 @@ public abstract class StateDialogActivity extends SyncthingActivity {
if (!isGeneratingKeys) {
new Handler().postDelayed(() -> {
if (isFinishing() || mLoadingDialog == null)
if (this.isFinishing() || mLoadingDialog == null)
return;
binding.loadingSlowMessage.setVisibility(View.VISIBLE);

View File

@ -47,7 +47,7 @@ import java.util.Properties;
* Holds a WebView that shows the web ui of the local syncthing instance.
*/
public class WebGuiActivity extends StateDialogActivity
implements SyncthingService.OnWebGuiAvailableListener {
implements SyncthingService.OnServiceStateChangeListener {
private static final String TAG = "WebGuiActivity";
@ -140,22 +140,33 @@ public class WebGuiActivity extends StateDialogActivity
mWebView.getSettings().setDomStorageEnabled(true);
mWebView.setWebViewClient(mWebViewClient);
mWebView.clearCache(true);
// SyncthingService needs to be started from this activity as the user
// can directly launch this activity from the recent activity switcher.
startService(new Intent(this, SyncthingService.class));
}
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
super.onServiceConnected(componentName, iBinder);
getService().registerOnWebGuiAvailableListener(WebGuiActivity.this);
getService().registerOnServiceStateChangeListener(this);
}
@Override
public void onWebGuiAvailable() {
public void onServiceStateChange(SyncthingService.State newState) {
Log.v(TAG, "onServiceStateChange(" + newState + ")");
if (newState == SyncthingService.State.ACTIVE) {
if (mWebView == null) {
Log.v(TAG, "onWebGuiAvailable: Skipped event due to mWebView == null");
return;
}
if (mWebView.getUrl() == null) {
mWebView.stopLoading();
setWebViewProxy(mWebView.getContext().getApplicationContext(), "", 0, "localhost|0.0.0.0|127.*|[::1]");
mWebView.loadUrl(getService().getWebGuiUrl().toString());
}
}
}
@Override
public void onBackPressed() {
@ -183,6 +194,10 @@ public class WebGuiActivity extends StateDialogActivity
@Override
protected void onDestroy() {
SyncthingService mSyncthingService = getService();
if (mSyncthingService != null) {
mSyncthingService.unregisterOnServiceStateChangeListener(this);
}
mWebView.destroy();
mWebView = null;
super.onDestroy();

View File

@ -27,7 +27,7 @@ import java.util.TimerTask;
/**
* Displays a list of all existing devices.
*/
public class DeviceListFragment extends ListFragment implements SyncthingService.OnApiChangeListener,
public class DeviceListFragment extends ListFragment implements SyncthingService.OnServiceStateChangeListener,
ListView.OnItemClickListener {
private final static Comparator<Device> DEVICES_COMPARATOR = (lhs, rhs) -> lhs.name.compareTo(rhs.name);
@ -45,7 +45,7 @@ public class DeviceListFragment extends ListFragment implements SyncthingService
}
@Override
public void onApiChange(SyncthingService.State currentState) {
public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != SyncthingService.State.ACTIVE)
return;

View File

@ -136,11 +136,20 @@ public class DrawerFragment extends Fragment implements View.OnClickListener {
* Invokes status callbacks.
*/
private void updateGui() {
if (mActivity.getApi() == null || getActivity() == null || getActivity().isFinishing())
MainActivity mainActivity = (MainActivity) getActivity();
if (mainActivity == null) {
return;
mActivity.getApi().getSystemInfo(this::onReceiveSystemInfo);
mActivity.getApi().getSystemVersion(this::onReceiveSystemVersion);
mActivity.getApi().getConnections(this::onReceiveConnections);
}
if (mainActivity.isFinishing()) {
return;
}
RestApi mApi = mainActivity.getApi();
if (mApi != null) {
mApi.getSystemInfo(this::onReceiveSystemInfo);
mApi.getSystemVersion(this::onReceiveSystemVersion);
mApi.getConnections(this::onReceiveConnections);
}
}
/**

View File

@ -24,7 +24,7 @@ import java.util.TimerTask;
/**
* Displays a list of all existing folders.
*/
public class FolderListFragment extends ListFragment implements SyncthingService.OnApiChangeListener,
public class FolderListFragment extends ListFragment implements SyncthingService.OnServiceStateChangeListener,
AdapterView.OnItemClickListener {
private FoldersAdapter mAdapter;
@ -40,7 +40,7 @@ public class FolderListFragment extends ListFragment implements SyncthingService
}
@Override
public void onApiChange(SyncthingService.State currentState) {
public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != SyncthingService.State.ACTIVE)
return;

View File

@ -94,13 +94,15 @@ public abstract class ApiRequest {
@Nullable OnSuccessListener listener, @Nullable OnErrorListener errorListener) {
Log.v(TAG, "Performing request to " + uri.toString());
StringRequest request = new StringRequest(requestMethod, uri.toString(), reply -> {
if (listener != null)
if (listener != null) {
listener.onSuccess(reply);
}
}, error -> {
if (errorListener != null)
if (errorListener != null) {
errorListener.onError(error);
else
Log.w(TAG, "Request to " + uri + " failed", error);
} else {
Log.w(TAG, "Request to " + uri + " failed, " + error.getMessage());
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {

View File

@ -25,9 +25,15 @@ public class PollWebGuiAvailableTask extends ApiRequest {
*/
private static final long WEB_GUI_POLL_INTERVAL = 100;
private final OnSuccessListener mListener;
private final Handler mHandler = new Handler();
private OnSuccessListener mListener;
/**
* Object that must be locked upon accessing mListener
*/
private final Object mListenerLock = new Object();
public PollWebGuiAvailableTask(Context context, URL url, String apiKey,
OnSuccessListener listener) {
super(context, url, "", apiKey);
@ -36,14 +42,36 @@ public class PollWebGuiAvailableTask extends ApiRequest {
performRequest();
}
public void cancelRequestsAndCallback() {
synchronized(mListenerLock) {
mListener = null;
}
}
private void performRequest() {
Uri uri = buildUri(Collections.emptyMap());
connect(Request.Method.GET, uri, null, mListener, this::onError);
connect(Request.Method.GET, uri, null, this::onSuccess, this::onError);
}
private void onSuccess(String result) {
synchronized(mListenerLock) {
if (mListener != null) {
mListener.onSuccess(result);
} else {
Log.v(TAG, "Cancelled callback and outstanding requests");
}
}
}
private void onError(VolleyError error) {
mHandler.postDelayed(this::performRequest, WEB_GUI_POLL_INTERVAL);
synchronized(mListenerLock) {
if (mListener == null) {
Log.v(TAG, "Cancelled callback and outstanding requests");
return;
}
}
mHandler.postDelayed(this::performRequest, WEB_GUI_POLL_INTERVAL);
Throwable cause = error.getCause();
if (cause == null || cause.getClass().equals(ConnectException.class)) {
Log.v(TAG, "Polling web gui");

View File

@ -8,6 +8,7 @@ import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import com.nutomic.syncthingandroid.service.DeviceStateHolder;
import com.nutomic.syncthingandroid.service.SyncthingService;
@ -17,6 +18,8 @@ import com.nutomic.syncthingandroid.service.SyncthingService;
*/
public class NetworkReceiver extends BroadcastReceiver {
private static final String TAG = "NetworkReceiver";
@Override
public void onReceive(Context context, Intent intent) {
if (!ConnectivityManager.CONNECTIVITY_ACTION.equals(intent.getAction()))
@ -33,14 +36,21 @@ public class NetworkReceiver extends BroadcastReceiver {
ConnectivityManager cm = (ConnectivityManager)
context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm.getActiveNetworkInfo();
boolean isOffline = ni == null;
boolean isWifi = ni != null && ni.getType() == ConnectivityManager.TYPE_WIFI && ni.isConnected();
boolean isAllowedConnectionType = false;
if (ni == null) {
Log.v(TAG, "In flight mode");
// We still allow opening MainActivity and WebGuiActivity for local administration.
isAllowedConnectionType = true;
} else {
Log.v(TAG, "Not in flight mode");
boolean isWifi = ni.getType() == ConnectivityManager.TYPE_WIFI && ni.isConnected();
boolean isNetworkMetered = (Build.VERSION.SDK_INT >= 16) ? cm.isActiveNetworkMetered() : false;
boolean isAllowedConnection = isOffline || (isWifi && !isNetworkMetered);
isAllowedConnectionType = isWifi && !isNetworkMetered;
}
LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
Intent intent = new Intent(DeviceStateHolder.ACTION_DEVICE_STATE_CHANGED);
intent.putExtra(DeviceStateHolder.EXTRA_IS_ALLOWED_NETWORK_CONNECTION, isAllowedConnection);
intent.putExtra(DeviceStateHolder.EXTRA_IS_ALLOWED_NETWORK_CONNECTION, isAllowedConnectionType);
lbm.sendBroadcast(intent);
}

View File

@ -20,6 +20,7 @@ import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.receiver.BatteryReceiver;
import com.nutomic.syncthingandroid.receiver.NetworkReceiver;
import com.nutomic.syncthingandroid.receiver.PowerSaveModeChangedReceiver;
import com.nutomic.syncthingandroid.service.ReceiverManager;
import java.util.HashSet;
import java.util.List;
@ -70,19 +71,26 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
private final Context mContext;
private final LocalBroadcastManager mBroadcastManager;
private final DeviceStateChangedReceiver mReceiver = new DeviceStateChangedReceiver();
private final OnDeviceStateChangedListener mListener;
@Inject SharedPreferences mPreferences;
private @Nullable NetworkReceiver mNetworkReceiver = null;
private @Nullable BatteryReceiver mBatteryReceiver = null;
private @Nullable BroadcastReceiver mPowerSaveModeChangedReceiver = null;
private @Nullable DeviceStateChangedReceiver mDeviceStateChangedReceiver = null;
private ReceiverManager mReceiverManager;
private boolean mIsAllowedNetworkConnection;
// Those receivers are managed by {@link mReceiverManager}.
private NetworkReceiver mNetworkReceiver;
private BatteryReceiver mBatteryReceiver;
private PowerSaveModeChangedReceiver mPowerSaveModeChangedReceiver;
private boolean mIsAllowedConnectionType;
private String mWifiSsid;
private boolean mIsCharging;
private boolean mIsPowerSaving;
/**
* Sending callback notifications through {@link OnDeviceStateChangedListener} is enabled if not null.
*/
private @Nullable OnDeviceStateChangedListener mOnDeviceStateChangedListener = null;
/**
* Stores the result of the last call to {@link decideShouldRun}.
*/
@ -92,21 +100,22 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
Log.v(TAG, "Created new instance");
((SyncthingApp) context.getApplicationContext()).component().inject(this);
mContext = context;
mBroadcastManager = LocalBroadcastManager.getInstance(mContext);
mBroadcastManager.registerReceiver(mReceiver, new IntentFilter(ACTION_DEVICE_STATE_CHANGED));
mPreferences.registerOnSharedPreferenceChangeListener(this);
mListener = listener;
updateReceivers();
mOnDeviceStateChangedListener = listener;
mDeviceStateChangedReceiver = new DeviceStateChangedReceiver();
mBroadcastManager = LocalBroadcastManager.getInstance(mContext);
mBroadcastManager.registerReceiver(mDeviceStateChangedReceiver, new IntentFilter(ACTION_DEVICE_STATE_CHANGED));
registerChildReceivers();
}
public void shutdown() {
Log.v(TAG, "Shutting down");
mBroadcastManager.unregisterReceiver(mReceiver);
mPreferences.unregisterOnSharedPreferenceChangeListener(this);
unregisterReceiver(mNetworkReceiver);
unregisterReceiver(mBatteryReceiver);
unregisterReceiver(mPowerSaveModeChangedReceiver);
mReceiverManager.unregisterAllReceivers(mContext);
if (mDeviceStateChangedReceiver != null) {
mBroadcastManager.unregisterReceiver(mDeviceStateChangedReceiver);
}
}
@Override
@ -115,64 +124,57 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
Constants.PREF_SYNC_ONLY_WIFI, Constants.PREF_RESPECT_BATTERY_SAVING,
Constants.PREF_SYNC_ONLY_WIFI_SSIDS);
if (watched.contains(key)) {
updateReceivers();
mReceiverManager.unregisterAllReceivers(mContext);
registerChildReceivers();
}
}
private void updateReceivers() {
private void registerChildReceivers() {
boolean incomingBroadcastEventsExpected = false;
if (mPreferences.getBoolean(Constants.PREF_SYNC_ONLY_WIFI, false)) {
Log.i(TAG, "Listening for network state changes");
Log.i(TAG, "Creating NetworkReceiver");
NetworkReceiver.updateNetworkStatus(mContext);
mNetworkReceiver = new NetworkReceiver();
mContext.registerReceiver(mNetworkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
} else {
Log.i(TAG, "Stopped listening to network state changes");
unregisterReceiver(mNetworkReceiver);
mNetworkReceiver = null;
ReceiverManager.registerReceiver(mContext, mNetworkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
incomingBroadcastEventsExpected = true;
}
if (mPreferences.getBoolean(Constants.PREF_SYNC_ONLY_CHARGING, false)) {
Log.i(TAG, "Listening to battery state changes");
Log.i(TAG, "Creating BatteryReceiver");
BatteryReceiver.updateInitialChargingStatus(mContext);
mBatteryReceiver = new BatteryReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_POWER_CONNECTED);
filter.addAction(Intent.ACTION_POWER_DISCONNECTED);
mContext.registerReceiver(mBatteryReceiver, filter);
} else {
Log.i(TAG, "Stopped listening to battery state changes");
unregisterReceiver(mBatteryReceiver);
mBatteryReceiver = null;
ReceiverManager.registerReceiver(mContext, mBatteryReceiver, filter);
incomingBroadcastEventsExpected = true;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
mPreferences.getBoolean("respect_battery_saving", true)) {
Log.i(TAG, "Listening to power saving changes");
mPreferences.getBoolean(Constants.PREF_RESPECT_BATTERY_SAVING, true)) {
Log.i(TAG, "Creating PowerSaveModeChangedReceiver");
PowerSaveModeChangedReceiver.updatePowerSavingState(mContext);
mPowerSaveModeChangedReceiver = new PowerSaveModeChangedReceiver();
mContext.registerReceiver(mPowerSaveModeChangedReceiver,
ReceiverManager.registerReceiver(mContext, mPowerSaveModeChangedReceiver,
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
} else {
Log.i(TAG, "Stopped listening to power saving changes");
unregisterReceiver(mPowerSaveModeChangedReceiver);
mPowerSaveModeChangedReceiver = null;
}
incomingBroadcastEventsExpected = true;
}
private void unregisterReceiver(BroadcastReceiver receiver) {
if (receiver != null)
mContext.unregisterReceiver(receiver);
// If no broadcast messages can be received as we didn't register an emitter,
// force an initial decision to be made.
if (!incomingBroadcastEventsExpected) {
updateShouldRunDecision();
}
}
private class DeviceStateChangedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
mIsAllowedNetworkConnection =
intent.getBooleanExtra(EXTRA_IS_ALLOWED_NETWORK_CONNECTION, mIsAllowedNetworkConnection);
mIsAllowedConnectionType =
intent.getBooleanExtra(EXTRA_IS_ALLOWED_NETWORK_CONNECTION, mIsAllowedConnectionType);
mIsCharging = intent.getBooleanExtra(EXTRA_IS_CHARGING, mIsCharging);
mIsPowerSaving = intent.getBooleanExtra(EXTRA_IS_POWER_SAVING, mIsPowerSaving);
Log.i(TAG, "State updated, allowed network connection: " + mIsAllowedNetworkConnection +
", charging: " + mIsCharging + ", power saving: " + mIsPowerSaving);
updateShouldRunDecision();
}
}
@ -193,7 +195,9 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
// compared to the last determined result.
boolean newShouldRun = decideShouldRun();
if (newShouldRun != lastDeterminedShouldRun) {
mListener.onDeviceStateChanged(newShouldRun);
if (mOnDeviceStateChangedListener != null) {
mOnDeviceStateChangedListener.onDeviceStateChanged(newShouldRun);
}
lastDeterminedShouldRun = newShouldRun;
}
}
@ -202,7 +206,10 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
* Determines if Syncthing should currently run.
*/
private boolean decideShouldRun() {
boolean prefRespectPowerSaving = mPreferences.getBoolean("respect_battery_saving", true);
Log.v(TAG, "State updated: IsAllowedConnectionType: " + mIsAllowedConnectionType +
", IsCharging: " + mIsCharging + ", IsPowerSaving: " + mIsPowerSaving);
boolean prefRespectPowerSaving = mPreferences.getBoolean(Constants.PREF_RESPECT_BATTERY_SAVING, true);
if (prefRespectPowerSaving && mIsPowerSaving)
return false;
@ -211,7 +218,7 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
boolean prefStopNotCharging = mPreferences.getBoolean(Constants.PREF_SYNC_ONLY_CHARGING, false);
updateWifiSsid();
if (prefStopMobileData && !isWhitelistedNetworkConnection())
if (prefStopMobileData && !isWhitelistedWifiConnection())
return false;
if (prefStopNotCharging && !mIsCharging)
@ -221,8 +228,8 @@ public class DeviceStateHolder implements SharedPreferences.OnSharedPreferenceCh
return true;
}
private boolean isWhitelistedNetworkConnection() {
boolean wifiConnected = mIsAllowedNetworkConnection;
private boolean isWhitelistedWifiConnection() {
boolean wifiConnected = mIsAllowedConnectionType;
if (wifiConnected) {
Set<String> ssids = mPreferences.getStringSet(Constants.PREF_SYNC_ONLY_WIFI_SSIDS, new HashSet<>());
if (ssids.isEmpty()) {

View File

@ -33,8 +33,7 @@ import javax.inject.Inject;
*
* It uses {@link RestApi#getEvents} to read the pending events and wait for new events.
*/
public class EventProcessor implements SyncthingService.OnWebGuiAvailableListener, Runnable,
RestApi.OnReceiveEventListener {
public class EventProcessor implements Runnable, RestApi.OnReceiveEventListener {
private static final String TAG = "EventProcessor";
private static final String PREF_LAST_SYNC_ID = "last_sync_id";
@ -67,7 +66,7 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
@Override
public void run() {
// Restore the last event id if the event processor may have been restartet.
// Restore the last event id if the event processor may have been restarted.
if (mLastEventId == 0) {
mLastEventId = mPreferences.getLong(PREF_LAST_SYNC_ID, 0);
}
@ -223,9 +222,8 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
}
}
@Override
public void onWebGuiAvailable() {
Log.d(TAG, "WebGUI available. Starting event processor.");
public void start() {
Log.d(TAG, "Starting event processor.");
// Remove all pending callbacks and add a new one. This makes sure that only one
// event poller is running at any given time.
@ -236,8 +234,8 @@ public class EventProcessor implements SyncthingService.OnWebGuiAvailableListene
}
}
public void shutdown() {
Log.d(TAG, "Shutdown event processor.");
public void stop() {
Log.d(TAG, "Stopping event processor.");
synchronized (mMainThreadHandler) {
mShutdown = true;
mMainThreadHandler.removeCallbacks(this);

View File

@ -16,6 +16,7 @@ import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.activities.FirstStartActivity;
import com.nutomic.syncthingandroid.activities.LogActivity;
import com.nutomic.syncthingandroid.activities.MainActivity;
import com.nutomic.syncthingandroid.service.SyncthingService.State;
import javax.inject.Inject;
@ -101,14 +102,32 @@ public class NotificationHandler {
type = "low_priority";
}
boolean syncthingRunning = service.getCurrentState() == SyncthingService.State.ACTIVE ||
service.getCurrentState() == SyncthingService.State.STARTING;
State currentServiceState = service.getCurrentState();
boolean syncthingRunning = currentServiceState == SyncthingService.State.ACTIVE ||
currentServiceState == SyncthingService.State.STARTING;
if (foreground || (syncthingRunning && !type.equals("none"))) {
// Launch FirstStartActivity instead of MainActivity so we can request permission if
// necessary.
PendingIntent pi = PendingIntent.getActivity(mContext, 0,
new Intent(mContext, FirstStartActivity.class), 0);
int title = syncthingRunning ? R.string.syncthing_active : R.string.syncthing_disabled;
int title = R.string.syncthing_terminated;
switch (currentServiceState) {
case ERROR:
case INIT:
break;
case DISABLED:
title = R.string.syncthing_disabled;
break;
case STARTING:
case ACTIVE:
title = R.string.syncthing_active;
break;
default:
break;
}
/**
* We no longer need to launch FirstStartActivity instead of MainActivity as
* {@link SyncthingService#onStartCommand} will check for denied permissions.
*/
Intent intent = new Intent(mContext, MainActivity.class);
// Reason for two separate IDs: if one of the notification channels is hidden then
// the startForeground() below won't update the notification but use the old one
int idToShow = syncthingRunning ? ID_PERSISTENT : ID_PERSISTENT_WAITING;
@ -119,7 +138,7 @@ public class NotificationHandler {
.setSmallIcon(R.drawable.ic_stat_notify)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(pi);
.setContentIntent(PendingIntent.getActivity(mContext, 0, intent, 0));
if (type.equals("low_priority"))
builder.setPriority(NotificationCompat.PRIORITY_MIN);

View File

@ -0,0 +1,50 @@
package com.nutomic.syncthingandroid.service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.util.Log;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class ReceiverManager {
private static final String TAG = "ReceiverManager";
private static List<BroadcastReceiver> mReceivers = new ArrayList<BroadcastReceiver>();
public static synchronized void registerReceiver(Context context, BroadcastReceiver receiver, IntentFilter intentFilter) {
mReceivers.add(receiver);
context.registerReceiver(receiver, intentFilter);
Log.v(TAG, "Registered receiver: " + receiver + " with filter: " + intentFilter);
}
public static synchronized boolean isReceiverRegistered(BroadcastReceiver receiver) {
return mReceivers.contains(receiver);
}
public static synchronized void unregisterAllReceivers(Context context) {
if (context == null) {
Log.e(TAG, "unregisterReceiver: context is null");
return;
}
Iterator<BroadcastReceiver> iter = mReceivers.iterator();
while (iter.hasNext()) {
BroadcastReceiver receiver = iter.next();
if (isReceiverRegistered(receiver)) {
try {
context.unregisterReceiver(receiver);
Log.v(TAG, "Unregistered receiver: " + receiver);
} catch(IllegalArgumentException e) {
// We have to catch the race condition a registration is still pending in android
// according to https://stackoverflow.com/a/3568906
Log.w(TAG, "unregisterReceiver(" + receiver + ") threw IllegalArgumentException");
}
iter.remove();
}
}
}
}

View File

@ -52,7 +52,7 @@ import javax.inject.Inject;
/**
* Provides functions to interact with the syncthing REST API.
*/
public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
public class RestApi {
private static final String TAG = "RestApi";
@ -97,6 +97,24 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
*/
private long mPreviousConnectionTime = 0;
/**
* In the last-finishing {@link readConfigFromRestApi} callback, we have to call
* {@link SyncthingService#onApiAvailable} to indicate that the RestApi class is fully initialized.
* We do this to avoid getting stuck with our main thread due to synchronous REST queries.
* The correct indication of full initialisation is crucial to stability as other listeners of
* {@link SettingsActivity#onServiceStateChange} needs cached config and system information available.
* e.g. SettingsFragment need "mLocalDeviceId"
*/
private Boolean asyncQueryConfigComplete = false;
private Boolean asyncQueryVersionComplete = false;
private Boolean asyncQuerySystemInfoComplete = false;
/**
* Object that must be locked upon accessing the following variables:
* asyncQueryConfigComplete, asyncQueryVersionComplete, asyncQuerySystemInfoComplete
*/
private final Object mAsyncQueryCompleteLock = new Object();
/**
* Stores the latest result of {@link #getFolderStatus} for each folder
*/
@ -119,16 +137,6 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
mOnConfigChangedListener = configListener;
}
/**
* Number of previous calls to {@link #tryIsAvailable()}.
*/
private final AtomicInteger mAvailableCount = new AtomicInteger(0);
/**
* Number of asynchronous calls performed in {@link #onWebGuiAvailable()}.
*/
private static final int TOTAL_STARTUP_CALLS = 3;
public interface OnApiAvailableListener {
void onApiAvailable();
}
@ -140,26 +148,46 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
/**
* Gets local device ID, syncthing version and config, then calls all OnApiAvailableListeners.
*/
@Override
public void onWebGuiAvailable() {
mAvailableCount.set(0);
public void readConfigFromRestApi() {
Log.v(TAG, "Reading config from REST ...");
synchronized (mAsyncQueryCompleteLock) {
asyncQueryVersionComplete = false;
asyncQueryConfigComplete = false;
asyncQuerySystemInfoComplete = false;
}
new GetRequest(mContext, mUrl, GetRequest.URI_VERSION, mApiKey, null, result -> {
JsonObject json = new JsonParser().parse(result).getAsJsonObject();
mVersion = json.get("version").getAsString();
Log.i(TAG, "Syncthing version is " + mVersion);
tryIsAvailable();
updateDebugFacilitiesCache();
synchronized (mAsyncQueryCompleteLock) {
asyncQueryVersionComplete = true;
checkReadConfigFromRestApiCompleted();
}
});
new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, result -> {
onReloadConfigComplete(result);
tryIsAvailable();
synchronized (mAsyncQueryCompleteLock) {
asyncQueryConfigComplete = true;
checkReadConfigFromRestApiCompleted();
}
});
getSystemInfo(info -> {
mLocalDeviceId = info.myID;
tryIsAvailable();
synchronized (mAsyncQueryCompleteLock) {
asyncQuerySystemInfoComplete = true;
checkReadConfigFromRestApiCompleted();
}
});
}
private void checkReadConfigFromRestApiCompleted() {
if (asyncQueryVersionComplete && asyncQueryConfigComplete && asyncQuerySystemInfoComplete) {
Log.v(TAG, "Reading config from REST completed.");
mOnApiAvailableListener.onApiAvailable();
}
}
public void reloadConfig() {
new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, this::onReloadConfigComplete);
}
@ -209,20 +237,6 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
}
}
/**
* Increments mAvailableCount by one, and, if it reached TOTAL_STARTUP_CALLS,
* calls {@link SyncthingService#onApiChange}.
*/
private void tryIsAvailable() {
int value = mAvailableCount.incrementAndGet();
if (BuildConfig.DEBUG && value > TOTAL_STARTUP_CALLS) {
throw new AssertionError("Too many startup calls");
}
if (value == TOTAL_STARTUP_CALLS) {
mOnApiAvailableListener.onApiAvailable();
}
}
/**
* Sends current config to Syncthing.
* Will result in a "ConfigSaved" event.
@ -311,12 +325,17 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener {
}
public Device getLocalDevice() {
for (Device d : getDevices(true)) {
List<Device> devices = getDevices(true);
if (devices.isEmpty()) {
throw new RuntimeException("RestApi.getLocalDevice: devices is empty.");
}
Log.v(TAG, "getLocalDevice: Looking for local device ID " + mLocalDeviceId);
for (Device d : devices) {
if (d.deviceID.equals(mLocalDeviceId)) {
return deepCopy(d, Device.class);
}
}
throw new RuntimeException();
throw new RuntimeException("RestApi.getLocalDevice: Failed to get the local device crucial to continuing execution.");
}
public void addDevice(Device device, OnResultListener1<String> errorListener) {

View File

@ -263,13 +263,10 @@ public class SyncthingRunnable implements Runnable {
* 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
List<String> syncthingPIDs = getSyncthingPIDs();
if (syncthingPIDs.isEmpty()) {
Log.w(TAG, "niceSyncthing: Found no running instances of " + Constants.FILENAME_SYNCTHING_BINARY);
@ -278,7 +275,7 @@ public class SyncthingRunnable implements Runnable {
niceOut = new DataOutputStream(nice.getOutputStream());
for (String syncthingPID : syncthingPIDs) {
// Set best-effort, low priority using ionice.
niceOut.writeBytes("ionice " + syncthingPID + " be 7\n");
niceOut.writeBytes("/system/bin/ionice " + syncthingPID + " be 7\n");
}
niceOut.writeBytes("exit\n");
log(nice.getErrorStream(), Log.WARN, false);
@ -304,8 +301,6 @@ public class SyncthingRunnable implements Runnable {
}
}
}
}.start();
}
public interface OnSyncthingKilled {
void onKilled();
@ -314,8 +309,7 @@ public class SyncthingRunnable implements Runnable {
* Look for running libsyncthing.so processes and kill them.
* Try a SIGINT first, then try again with SIGKILL.
*/
public void killSyncthing(OnSyncthingKilled onKilledListener) {
new Thread(() -> {
public void killSyncthing() {
for (int i = 0; i < 2; i++) {
List<String> syncthingPIDs = getSyncthingPIDs();
if (syncthingPIDs.isEmpty()) {
@ -327,8 +321,6 @@ public class SyncthingRunnable implements Runnable {
killProcessId(syncthingPID, i > 0);
}
}
onKilledListener.onKilled();
}).start();
}
/**

View File

@ -59,18 +59,8 @@ public class SyncthingService extends Service {
public static final String ACTION_REFRESH_NETWORK_INFO =
"com.nutomic.syncthingandroid.service.SyncthingService.REFRESH_NETWORK_INFO";
/**
* Callback for when the Syncthing web interface becomes first available after service start.
*/
public interface OnWebGuiAvailableListener {
void onWebGuiAvailable();
}
private final HashSet<OnWebGuiAvailableListener> mOnWebGuiAvailableListeners =
new HashSet<>();
public interface OnApiChangeListener {
void onApiChange(State currentState);
public interface OnServiceStateChangeListener {
void onServiceStateChange(State currentState);
}
/**
@ -81,40 +71,54 @@ public class SyncthingService extends Service {
INIT,
/** Syncthing binary is starting. */
STARTING,
/** Syncthing binary is running, API is available. */
/** Syncthing binary is running,
* Rest API is available,
* RestApi class read the config and is fully initialized.
*/
ACTIVE,
/** Syncthing is stopped according to user preferences. */
/** Syncthing binary is shutting down. */
DISABLED,
/** There is some problem that prevents Syncthing from running. */
ERROR,
}
private State mCurrentState = State.INIT;
/**
* Initialize the service with State.DISABLED as {@link DeviceStateHolder} will
* send an update if we should run the binary after it got instantiated in
* {@link onStartCommand}.
*/
private State mCurrentState = State.DISABLED;
private ConfigXml mConfig;
private RestApi mApi;
private EventProcessor mEventProcessor;
private @Nullable PollWebGuiAvailableTask mPollWebGuiAvailableTask = null;
private @Nullable RestApi mApi = null;
private @Nullable EventProcessor mEventProcessor = null;
private @Nullable DeviceStateHolder mDeviceStateHolder = null;
private SyncthingRunnable mSyncthingRunnable;
private @Nullable SyncthingRunnable mSyncthingRunnable = null;
private Thread mSyncthingRunnableThread = null;
private Handler mHandler;
private final HashSet<OnApiChangeListener> mOnApiChangeListeners = new HashSet<>();
private final HashSet<OnServiceStateChangeListener> mOnServiceStateChangeListeners = new HashSet<>();
private final SyncthingServiceBinder mBinder = new SyncthingServiceBinder(this);
@Inject NotificationHandler mNotificationHandler;
@Inject SharedPreferences mPreferences;
/**
* Object that can be locked upon when accessing mCurrentState
* Currently used to male onDestroy() and PollWebGuiAvailableTaskImpl.onPostExcecute() tread-safe
* Object that must be locked upon accessing mCurrentState
*/
private final Object mStateLock = new Object();
/**
* True if a stop was requested while syncthing is starting, in that case, perform stop in
* {@link #pollWebGui}.
* Stores the result of the last should run decision received by OnDeviceStateChangedListener.
*/
private boolean mStopScheduled = false;
private boolean mLastDeterminedShouldRun = false;
/**
* True if a service {@link onDestroy} was requested while syncthing is starting,
* in that case, perform stop in {@link onApiAvailable}.
*/
private boolean mDestroyScheduled = false;
/**
* True if the user granted the storage permission.
@ -126,6 +130,7 @@ public class SyncthingService extends Service {
*/
@Override
public void onCreate() {
Log.v(TAG, "onCreate");
super.onCreate();
PRNGFixes.apply();
((SyncthingApp) getApplication()).component().inject(this);
@ -148,6 +153,7 @@ public class SyncthingService extends Service {
*/
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.v(TAG, "onStartCommand");
if (!mStoragePermissionGranted) {
Log.e(TAG, "User revoked storage permission. Stopping service.");
if (mNotificationHandler != null) {
@ -157,7 +163,27 @@ public class SyncthingService extends Service {
return START_NOT_STICKY;
}
/**
* Send current service state to listening endpoints.
* This is required that components know about the service State.DISABLED
* if DeviceStateHolder does not send a "shouldRun = true" callback
* to start the binary according to preferences shortly after its creation.
* See {@link mLastDeterminedShouldRun} defaulting to "false".
*/
if (mCurrentState == State.DISABLED) {
synchronized(mStateLock) {
onServiceStateChange(mCurrentState);
}
}
if (mDeviceStateHolder == null) {
/**
* Instantiate the run condition monitor on first onStartCommand and
* enable callback on run condition change affecting the final decision to
* run/terminate syncthing. After initial run conditions are collected
* the first decision is sent to {@link onUpdatedShouldRunDecision}.
*/
mDeviceStateHolder = new DeviceStateHolder(SyncthingService.this, this::onUpdatedShouldRunDecision);
}
mNotificationHandler.updatePersistentNotification(this);
if (intent == null)
@ -185,10 +211,15 @@ public class SyncthingService extends Service {
* After run conditions monitored by {@link DeviceStateHolder} changed and
* it had an influence on the decision to run/terminate syncthing, this
* function is called to notify this class to run/terminate the syncthing binary.
* {@link #onApiChange} is called while applying the decision change.
* {@link #onServiceStateChange} is called while applying the decision change.
*/
private void onUpdatedShouldRunDecision(boolean shouldRun) {
if (shouldRun) {
private void onUpdatedShouldRunDecision(boolean newShouldRunDecision) {
if (newShouldRunDecision != mLastDeterminedShouldRun) {
Log.i(TAG, "shouldRun decision changed to " + newShouldRunDecision + " according to configured run conditions.");
mLastDeterminedShouldRun = newShouldRunDecision;
// React to the shouldRun condition change.
if (newShouldRunDecision) {
// Start syncthing.
switch (mCurrentState) {
case DISABLED:
@ -196,26 +227,27 @@ public class SyncthingService extends Service {
// HACK: Make sure there is no syncthing binary left running from an improper
// shutdown (eg Play Store update).
shutdown(State.INIT, () -> {
Log.i(TAG, "Starting syncthing according to current state and preferences after State.INIT");
Log.v(TAG, "Starting syncthing");
new StartupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
});
break;
case STARTING:
case ACTIVE:
mStopScheduled = false;
case ERROR:
break;
default:
break;
}
} else {
// Stop syncthing.
if (mCurrentState == State.DISABLED)
if (mCurrentState == State.DISABLED) {
return;
Log.i(TAG, "Stopping syncthing according to current state and preferences");
}
Log.v(TAG, "Stopping syncthing");
shutdown(State.DISABLED, () -> {});
}
}
}
/**
* Sets up the initial configuration, and updates the config when coming from an old
@ -223,8 +255,16 @@ public class SyncthingService extends Service {
*/
private class StartupTask extends AsyncTask<Void, Void, Void> {
public StartupTask() {
onApiChange(State.STARTING);
@Override
protected void onPreExecute() {
synchronized(mStateLock) {
if (mCurrentState != State.INIT) {
Log.e(TAG, "StartupTask: Wrong state " + mCurrentState + " detected. Cancelling.");
cancel(true);
return;
}
onServiceStateChange(State.STARTING);
}
}
@Override
@ -234,7 +274,9 @@ public class SyncthingService extends Service {
mConfig.updateIfNeeded();
} catch (ConfigXml.OpenConfigException e) {
mNotificationHandler.showCrashedNotification(R.string.config_create_failed, true);
onApiChange(State.ERROR);
synchronized (mStateLock) {
onServiceStateChange(State.ERROR);
}
cancel(true);
}
return null;
@ -242,26 +284,77 @@ public class SyncthingService extends Service {
@Override
protected void onPostExecute(Void aVoid) {
if (mApi == null) {
mApi = new RestApi(SyncthingService.this, mConfig.getWebGuiUrl(), mConfig.getApiKey(),
SyncthingService.this::onSyncthingStarted, () -> onApiChange(mCurrentState));
mEventProcessor = new EventProcessor(SyncthingService.this, mApi);
if (mApi != null)
registerOnWebGuiAvailableListener(mApi);
if (mEventProcessor != null)
registerOnWebGuiAvailableListener(mEventProcessor);
SyncthingService.this::onApiAvailable, () -> onServiceStateChange(mCurrentState));
Log.i(TAG, "Web GUI will be available at " + mConfig.getWebGuiUrl());
}
pollWebGui();
// Start the syncthing binary.
if (mSyncthingRunnable != null || mSyncthingRunnableThread != null) {
Log.e(TAG, "StartupTask/onPostExecute: Syncthing binary lifecycle violated");
return;
}
mSyncthingRunnable = new SyncthingRunnable(SyncthingService.this, SyncthingRunnable.Command.main);
new Thread(mSyncthingRunnable).start();
mSyncthingRunnableThread = new Thread(mSyncthingRunnable);
mSyncthingRunnableThread.start();
/**
* 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.
*/
if (mPollWebGuiAvailableTask == null) {
mPollWebGuiAvailableTask = new PollWebGuiAvailableTask(
SyncthingService.this,
getWebGuiUrl(),
mConfig.getApiKey(),
result -> {
Log.i(TAG, "Web GUI has come online at " + mConfig.getWebGuiUrl());
if (mApi != null) {
mApi.readConfigFromRestApi();
}
});
}
}
}
private void onSyncthingStarted() {
onApiChange(State.ACTIVE);
Log.i(TAG, "onSyncthingStarted(): State.ACTIVE reached.");
/**
* Called when {@link PollWebGuiAvailableTask} confirmed the REST API is available.
* We can assume mApi being available under normal conditions.
* UI stressing results in mApi getting null on simultaneous shutdown, so
* we check it for safety.
*/
private void onApiAvailable() {
if (mApi == null) {
Log.e(TAG, "onApiAvailable: Did we stop the binary during startup? mApi == null");
return;
}
synchronized (mStateLock) {
if (mCurrentState != State.STARTING) {
Log.e(TAG, "onApiAvailable: Wrong state " + mCurrentState + " detected. Cancelling callback.");
return;
}
onServiceStateChange(State.ACTIVE);
}
/**
* If the service instance got an onDestroy() event while being in
* State.STARTING we'll trigger the service onDestroy() now. this
* allows the syncthing binary to get gracefully stopped.
*/
if (mDestroyScheduled) {
mDestroyScheduled = false;
stopSelf();
return;
}
if (mEventProcessor == null) {
mEventProcessor = new EventProcessor(SyncthingService.this, mApi);
mEventProcessor.start();
}
}
@Override
@ -271,18 +364,23 @@ public class SyncthingService extends Service {
/**
* 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.
* Shuts down DeviceStateHolder instance.
*/
@Override
public void onDestroy() {
Log.v(TAG, "onDestroy");
if (mDeviceStateHolder != null) {
/**
* Shut down the OnDeviceStateChangedListener so we won't get interrupted by run
* condition events that occur during shutdown.
*/
mDeviceStateHolder.shutdown();
}
if (mStoragePermissionGranted) {
synchronized (mStateLock) {
if (mCurrentState == State.INIT || mCurrentState == State.STARTING) {
if (mCurrentState == State.STARTING) {
Log.i(TAG, "Delay shutting down synchting binary until initialisation finished");
mStopScheduled = true;
mDestroyScheduled = true;
} else {
Log.i(TAG, "Shutting down syncthing binary immediately");
shutdown(State.DISABLED, () -> {});
@ -294,10 +392,7 @@ public class SyncthingService extends Service {
Log.i(TAG, "Shutting down syncthing binary due to missing storage permission.");
shutdown(State.DISABLED, () -> {});
}
if (mDeviceStateHolder != null) {
mDeviceStateHolder.shutdown();
}
super.onDestroy();
}
/**
@ -307,38 +402,45 @@ public class SyncthingService extends Service {
*/
private void shutdown(State newState, SyncthingRunnable.OnSyncthingKilled onKilledListener) {
Log.i(TAG, "Shutting down background service");
onApiChange(newState);
synchronized(mStateLock) {
onServiceStateChange(newState);
}
if (mEventProcessor != null)
mEventProcessor.shutdown();
if (mPollWebGuiAvailableTask != null) {
mPollWebGuiAvailableTask.cancelRequestsAndCallback();
mPollWebGuiAvailableTask = null;
}
if (mApi != null)
if (mEventProcessor != null) {
mEventProcessor.stop();
mEventProcessor = null;
}
if (mApi != null) {
mApi.shutdown();
mApi = null;
}
if (mNotificationHandler != null)
if (mNotificationHandler != null) {
mNotificationHandler.cancelPersistentNotification(this);
}
if (mSyncthingRunnable != null) {
mSyncthingRunnable.killSyncthing(onKilledListener);
mSyncthingRunnable.killSyncthing();
if (mSyncthingRunnableThread != null) {
Log.v(TAG, "Waiting for mSyncthingRunnableThread to finish after killSyncthing ...");
try {
mSyncthingRunnableThread.join();
} catch (InterruptedException e) {
Log.w(TAG, "mSyncthingRunnableThread InterruptedException");
}
Log.v(TAG, "Finished mSyncthingRunnableThread.");
mSyncthingRunnableThread = null;
}
mSyncthingRunnable = null;
} else {
}
onKilledListener.onKilled();
}
}
/**
* 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);
}
}
public @Nullable RestApi getApi() {
return mApi;
@ -350,60 +452,37 @@ public class SyncthingService extends Service {
* 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
* @see #unregisterOnServiceStateChangeListener
*/
public void registerOnApiChangeListener(OnApiChangeListener listener) {
public void registerOnServiceStateChangeListener(OnServiceStateChangeListener 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);
listener.onServiceStateChange(mCurrentState);
mOnServiceStateChangeListeners.add(listener);
}
/**
* Unregisters a previously registered listener.
*
* @see #registerOnApiChangeListener
* @see #registerOnServiceStateChangeListener
*/
public void unregisterOnApiChangeListener(OnApiChangeListener listener) {
mOnApiChangeListeners.remove(listener);
}
/**
* 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.
*/
private void pollWebGui() {
new PollWebGuiAvailableTask(this, getWebGuiUrl(), mConfig.getApiKey(), result -> {
synchronized (mStateLock) {
if (mStopScheduled) {
shutdown(State.DISABLED, () -> {});
mStopScheduled = false;
stopSelf();
return;
}
}
Log.i(TAG, "Web GUI has come online at " + mConfig.getWebGuiUrl());
onApiChange(State.STARTING);
Stream.of(mOnWebGuiAvailableListeners).forEach(OnWebGuiAvailableListener::onWebGuiAvailable);
mOnWebGuiAvailableListeners.clear();
});
public void unregisterOnServiceStateChangeListener(OnServiceStateChangeListener listener) {
mOnServiceStateChangeListeners.remove(listener);
}
/**
* Called to notifiy listeners of an API change.
*/
private void onApiChange(State newState) {
mHandler.post(() -> {
private void onServiceStateChange(State newState) {
Log.v(TAG, "onServiceStateChange: from " + mCurrentState + " to " + newState);
mCurrentState = newState;
mHandler.post(() -> {
mNotificationHandler.updatePersistentNotification(this);
for (Iterator<OnApiChangeListener> i = mOnApiChangeListeners.iterator();
for (Iterator<OnServiceStateChangeListener> i = mOnServiceStateChangeListeners.iterator();
i.hasNext(); ) {
OnApiChangeListener listener = i.next();
OnServiceStateChangeListener listener = i.next();
if (listener != null) {
listener.onApiChange(mCurrentState);
listener.onServiceStateChange(mCurrentState);
} else {
i.remove();
}