package com.nutomic.syncthingandroid.activities; import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.support.v4.content.ContextCompat; import android.support.v7.app.AlertDialog; import android.support.v7.widget.SwitchCompat; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.AdapterView; import android.widget.CompoundButton; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; import com.google.gson.Gson; import com.google.zxing.integration.android.IntentIntegrator; import com.google.zxing.integration.android.IntentResult; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.model.Connections; import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.util.Compression; import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.Util; import java.util.Arrays; import java.util.Collections; import java.util.List; import static android.text.TextUtils.isEmpty; import static android.view.View.GONE; import static android.view.View.VISIBLE; import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN; import static com.nutomic.syncthingandroid.service.SyncthingService.State.ACTIVE; import static com.nutomic.syncthingandroid.util.Compression.METADATA; /** * Shows device details and allows changing them. */ public class DeviceActivity extends SyncthingActivity implements View.OnClickListener { public static final String EXTRA_DEVICE_ID = "com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_ID"; public static final String EXTRA_IS_CREATE = "com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE"; private static final String TAG = "DeviceSettingsFragment"; private static final String IS_SHOWING_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE"; private static final String IS_SHOWING_COMPRESSION_DIALOG = "COMPRESSION_FOLDER_DIALOG_STATE"; private static final String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE"; public static final List<String> DYNAMIC_ADDRESS = Collections.singletonList("dynamic"); private Device mDevice; private View mIdContainer; private EditText mIdView; private View mQrButton; private EditText mNameView; private EditText mAddressesView; private TextView mCurrentAddressView; private TextView mCompressionValueView; private SwitchCompat mIntroducerView; private TextView mSyncthingVersionView; private View mCompressionContainer; private boolean mIsCreateMode; private boolean mDeviceNeedsToUpdate; private Dialog mDeleteDialog; private Dialog mDiscardDialog; private Dialog mCompressionDialog; private final DialogInterface.OnClickListener mCompressionEntrySelectedListener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); Compression compression = Compression.fromIndex(which); // Don't pop the restart dialog unless the value is actually different. if (compression != Compression.fromValue(DeviceActivity.this, mDevice.compression)) { mDeviceNeedsToUpdate = true; mDevice.compression = compression.getValue(DeviceActivity.this); mCompressionValueView.setText(compression.getTitle(DeviceActivity.this)); } } }; private final TextWatcher mIdTextWatcher = new TextWatcherAdapter() { @Override public void afterTextChanged(Editable s) { if (!s.toString().equals(mDevice.deviceID)) { mDeviceNeedsToUpdate = true; mDevice.deviceID = s.toString(); } } }; private final TextWatcher mNameTextWatcher = new TextWatcherAdapter() { @Override public void afterTextChanged(Editable s) { if (!s.toString().equals(mDevice.name)) { mDeviceNeedsToUpdate = true; mDevice.name = s.toString(); } } }; private final TextWatcher mAddressesTextWatcher = new TextWatcherAdapter() { @Override public void afterTextChanged(Editable s) { if (!s.toString().equals(displayableAddresses())) { mDeviceNeedsToUpdate = true; mDevice.addresses = persistableAddresses(s); } } }; private final CompoundButton.OnCheckedChangeListener mIntroducerCheckedChangeListener = new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { if (mDevice.introducer != isChecked) { mDeviceNeedsToUpdate = true; mDevice.introducer = isChecked; } } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.fragment_device); mIsCreateMode = getIntent().getBooleanExtra(EXTRA_IS_CREATE, false); registerOnServiceConnectedListener(this::onServiceConnected); setTitle(mIsCreateMode ? R.string.add_device : R.string.edit_device); mIdContainer = findViewById(R.id.idContainer); mIdView = (EditText) findViewById(R.id.id); mQrButton = findViewById(R.id.qrButton); mNameView = (EditText) findViewById(R.id.name); mAddressesView = (EditText) findViewById(R.id.addresses); mCurrentAddressView = (TextView) findViewById(R.id.currentAddress); mCompressionContainer = findViewById(R.id.compressionContainer); mCompressionValueView = (TextView) findViewById(R.id.compressionValue); mIntroducerView = (SwitchCompat) findViewById(R.id.introducer); mSyncthingVersionView = (TextView) findViewById(R.id.syncthingVersion); mQrButton.setOnClickListener(this); mCompressionContainer.setOnClickListener(this); if (savedInstanceState != null){ if (mDevice == null) { mDevice = new Gson().fromJson(savedInstanceState.getString("device"), Device.class); } restoreDialogStates(savedInstanceState); } if (mIsCreateMode) { if (mDevice == null) { initDevice(); } } else { prepareEditMode(); } } private void restoreDialogStates(Bundle savedInstanceState) { if (savedInstanceState.getBoolean(IS_SHOWING_COMPRESSION_DIALOG)){ showCompressionDialog(); } if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)){ showDeleteDialog(); } if (mIsCreateMode){ if (savedInstanceState.getBoolean(IS_SHOWING_DISCARD_DIALOG)){ showDiscardDialog(); } } } @Override public void onDestroy() { super.onDestroy(); if (getService() != null) { getService().unregisterOnApiChangeListener(this::onApiChange); } mIdView.removeTextChangedListener(mIdTextWatcher); mNameView.removeTextChangedListener(mNameTextWatcher); mAddressesView.removeTextChangedListener(mAddressesTextWatcher); } @Override public void onPause() { super.onPause(); // We don't want to update every time a TextView's character changes, // so we hold off until the view stops being visible to the user. if (mDeviceNeedsToUpdate) { updateDevice(); } } /** * Save current settings in case we are in create mode and they aren't yet stored in the config. */ @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString("device", new Gson().toJson(mDevice)); if (mIsCreateMode){ outState.putBoolean(IS_SHOWING_DISCARD_DIALOG, mDiscardDialog != null && mDiscardDialog.isShowing()); if(mDiscardDialog != null){ mDiscardDialog.cancel(); } } outState.putBoolean(IS_SHOWING_COMPRESSION_DIALOG, mCompressionDialog != null && mCompressionDialog.isShowing()); if(mCompressionDialog != null){ mCompressionDialog.cancel(); } outState.putBoolean(IS_SHOWING_DELETE_DIALOG, mDeleteDialog != null && mDeleteDialog.isShowing()); if (mDeleteDialog != null) { mDeleteDialog.cancel(); } } public void onServiceConnected() { getService().registerOnApiChangeListener(this::onApiChange); } /** * Sets version and current address of the device. * <p/> * NOTE: This is only called once on startup, should be called more often to properly display * version/address changes. */ public void onReceiveConnections(Connections connections) { boolean viewsExist = mSyncthingVersionView != null && mCurrentAddressView != null; if (viewsExist && connections.connections.containsKey(mDevice.deviceID)) { mCurrentAddressView.setVisibility(VISIBLE); mSyncthingVersionView.setVisibility(VISIBLE); mCurrentAddressView.setText(connections.connections.get(mDevice.deviceID).address); mSyncthingVersionView.setText(connections.connections.get(mDevice.deviceID).clientVersion); } } public void onApiChange(SyncthingService.State currentState) { if (currentState != ACTIVE) { finish(); return; } if (!mIsCreateMode) { List<Device> devices = getApi().getDevices(false); mDevice = null; for (Device device : devices) { if (device.deviceID.equals(getIntent().getStringExtra(EXTRA_DEVICE_ID))) { mDevice = device; break; } } if (mDevice == null) { Log.w(TAG, "Device not found in API update, maybe it was deleted?"); finish(); return; } } getApi().getConnections(this::onReceiveConnections); updateViewsAndSetListeners(); } private void updateViewsAndSetListeners() { // Update views mIdView.setText(mDevice.deviceID); mNameView.setText(mDevice.name); mAddressesView.setText(displayableAddresses()); mCompressionValueView.setText(Compression.fromValue(this, mDevice.compression).getTitle(this)); mIntroducerView.setChecked(mDevice.introducer); // Keep state updated mIdView.addTextChangedListener(mIdTextWatcher); mNameView.addTextChangedListener(mNameTextWatcher); mAddressesView.addTextChangedListener(mAddressesTextWatcher); mIntroducerView.setOnCheckedChangeListener(mIntroducerCheckedChangeListener); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.device_settings, menu); return super.onCreateOptionsMenu(menu); } @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.create).setVisible(mIsCreateMode); menu.findItem(R.id.share_device_id).setVisible(!mIsCreateMode); menu.findItem(R.id.remove).setVisible(!mIsCreateMode); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.create: if (isEmpty(mDevice.deviceID)) { Toast.makeText(this, R.string.device_id_required, Toast.LENGTH_LONG) .show(); return true; } getApi().addDevice(mDevice, error -> Toast.makeText(this, error, Toast.LENGTH_LONG).show()); finish(); return true; case R.id.share_device_id: shareDeviceId(this, mDevice.deviceID); return true; case R.id.remove: showDeleteDialog(); return true; case android.R.id.home: onBackPressed(); return true; default: return super.onOptionsItemSelected(item); } } private void showDeleteDialog(){ mDeleteDialog = createDeleteDialog(); mDeleteDialog.show(); } private Dialog createDeleteDialog(){ return new android.app.AlertDialog.Builder(this) .setMessage(R.string.remove_device_confirm) .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { getApi().removeDevice(mDevice.deviceID); finish(); }) .setNegativeButton(android.R.string.no, null) .create(); } /** * Receives value of scanned QR code and sets it as device ID. */ @Override public void onActivityResult(int requestCode, int resultCode, Intent intent) { IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent); if (scanResult != null) { mDevice.deviceID = scanResult.getContents(); mIdView.setText(mDevice.deviceID); } } private void initDevice() { mDevice = new Device(); mDevice.name = ""; mDevice.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID); mDevice.addresses = DYNAMIC_ADDRESS; mDevice.compression = METADATA.getValue(this); mDevice.introducer = false; } private void prepareEditMode() { getWindow().setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_HIDDEN); Drawable dr = ContextCompat.getDrawable(this, R.drawable.ic_content_copy_black_24dp); mIdView.setCompoundDrawablesWithIntrinsicBounds(null, null, dr, null); mIdView.setEnabled(false); mQrButton.setVisibility(GONE); mIdContainer.setOnClickListener(this); } /** * Sends the updated device info if in edit mode. */ private void updateDevice() { if (!mIsCreateMode && mDeviceNeedsToUpdate && mDevice != null) { getApi().editDevice(mDevice); } } private List<String> persistableAddresses(CharSequence userInput) { return isEmpty(userInput) ? DYNAMIC_ADDRESS : Arrays.asList(userInput.toString().split(" ")); } private String displayableAddresses() { List<String> list = DYNAMIC_ADDRESS.equals(mDevice.addresses) ? DYNAMIC_ADDRESS : mDevice.addresses; return TextUtils.join(" ", list); } @Override public void onClick(View v) { if (v.equals(mCompressionContainer)) { showCompressionDialog(); } else if (v.equals(mQrButton)){ IntentIntegrator integrator = new IntentIntegrator(DeviceActivity.this); integrator.initiateScan(); } else if (v.equals(mIdContainer)) { Util.copyDeviceId(this, mDevice.deviceID); } } private void showCompressionDialog(){ mCompressionDialog = createCompressionDialog(); mCompressionDialog.show(); } private Dialog createCompressionDialog(){ return new AlertDialog.Builder(this) .setTitle(R.string.compression) .setSingleChoiceItems(R.array.compress_entries, Compression.fromValue(this, mDevice.compression).getIndex(), mCompressionEntrySelectedListener) .create(); } /** * Shares the given device ID via Intent. Must be called from an Activity. */ private void shareDeviceId(Context context, String id) { Intent shareIntent = new Intent(); shareIntent.setAction(Intent.ACTION_SEND); shareIntent.setType("text/plain"); shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, id); context.startActivity(Intent.createChooser( shareIntent, context.getString(R.string.send_device_id_to))); } @Override public void onBackPressed() { if (mIsCreateMode) { showDiscardDialog(); } else { super.onBackPressed(); } } private void showDiscardDialog(){ mDiscardDialog = createDiscardDialog(); mDiscardDialog.show(); } private Dialog createDiscardDialog() { return new android.app.AlertDialog.Builder(this) .setMessage(R.string.dialog_discard_changes) .setPositiveButton(android.R.string.ok, (dialog, which) -> finish()) .setNegativeButton(android.R.string.cancel, null) .create(); } }