diff options
| author | Harsh Shandilya <msfjarvis@gmail.com> | 2019-10-01 22:14:28 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-10-01 22:14:28 +0530 |
| commit | 073346c157ec34b750d175c8543f346e6eae94de (patch) | |
| tree | a55d24ef43563f3a2d03d7938a1e96c1f5cd1caf /app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt | |
| parent | c9dc4034f12b62f4470f92fb13ecd72871f4a5b7 (diff) | |
| download | HardenedPass-073346c157ec34b750d175c8543f346e6eae94de.tar.gz HardenedPass-073346c157ec34b750d175c8543f346e6eae94de.zip | |
Redesign UI and introduce dark theme (#519)
Caveats:
- The openpgp preference had to be removed because the open-intents developers are too lazy to update their libraries. Over the coming weeks I will be reimplementing a local solution for this instead.
- The autofill dialog is broken but I since it is being worked on in #410 already I'm not going to bother fixing it.
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt')
| -rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt | 660 |
1 files changed, 660 insertions, 0 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt new file mode 100644 index 00000000..11cc4c1b --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt @@ -0,0 +1,660 @@ +package com.zeapo.pwdstore.git + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.text.Editable +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.ArrayAdapter +import android.widget.Spinner +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatTextView +import androidx.preference.PreferenceManager +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.git.config.SshApiSessionFactory +import com.zeapo.pwdstore.utils.PasswordRepository +import org.apache.commons.io.FileUtils +import org.eclipse.jgit.lib.Constants +import java.io.File +import java.io.IOException +import java.util.regex.Pattern + +open class GitActivity : AppCompatActivity() { + private lateinit var context: Context + private lateinit var settings: SharedPreferences + private lateinit var protocol: String + private lateinit var connectionMode: String + private lateinit var hostname: String + private var identityBuilder: SshApiSessionFactory.IdentityBuilder? = null + private var identity: SshApiSessionFactory.ApiIdentity? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + context = requireNotNull(this) + + settings = PreferenceManager.getDefaultSharedPreferences(this) + + protocol = settings.getString("git_remote_protocol", null) ?: "ssh://" + connectionMode = settings.getString("git_remote_auth", null) ?: "ssh-key" + hostname = settings.getString("git_remote_location", null) ?: "" + val operationCode = intent.extras!!.getInt("Operation") + + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + when (operationCode) { + REQUEST_CLONE, EDIT_SERVER -> { + setContentView(R.layout.activity_git_clone) + setTitle(R.string.title_activity_git_clone) + + val protcolSpinner = findViewById<Spinner>(R.id.clone_protocol) + val connectionModeSpinner = findViewById<Spinner>(R.id.connection_mode) + + // init the spinner for connection modes + val connectionModeAdapter = ArrayAdapter.createFromResource(this, + R.array.connection_modes, android.R.layout.simple_spinner_item) + connectionModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + connectionModeSpinner.adapter = connectionModeAdapter + connectionModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(adapterView: AdapterView<*>, view: View, i: Int, l: Long) { + val selection = (findViewById<View>(R.id.connection_mode) as Spinner).selectedItem.toString() + connectionMode = selection + settings.edit().putString("git_remote_auth", selection).apply() + } + + override fun onNothingSelected(adapterView: AdapterView<*>) { + + } + } + + // init the spinner for protocols + val protocolAdapter = ArrayAdapter.createFromResource(this, + R.array.clone_protocols, android.R.layout.simple_spinner_item) + protocolAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + protcolSpinner.adapter = protocolAdapter + protcolSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(adapterView: AdapterView<*>, view: View, i: Int, l: Long) { + protocol = (findViewById<View>(R.id.clone_protocol) as Spinner).selectedItem.toString() + if (protocol == "ssh://") { + + // select ssh-key auth mode as default and enable the spinner in case it was disabled + connectionModeSpinner.setSelection(0) + connectionModeSpinner.isEnabled = true + + // however, if we have some saved that, that's more important! + when { + connectionMode.equals("ssh-key", ignoreCase = true) -> connectionModeSpinner.setSelection(0) + connectionMode.equals("OpenKeychain", ignoreCase = true) -> connectionModeSpinner.setSelection(2) + else -> connectionModeSpinner.setSelection(1) + } + } else { + // select user/pwd auth-mode and disable the spinner + connectionModeSpinner.setSelection(1) + connectionModeSpinner.isEnabled = false + } + + updateURI() + } + + override fun onNothingSelected(adapterView: AdapterView<*>) { + + } + } + + if (protocol == "ssh://") { + protcolSpinner.setSelection(0) + } else { + protcolSpinner.setSelection(1) + } + + // init the server information + val serverUrl = findViewById<TextInputEditText>(R.id.server_url) + val serverPort = findViewById<TextInputEditText>(R.id.server_port) + val serverPath = findViewById<TextInputEditText>(R.id.server_path) + val serverUser = findViewById<TextInputEditText>(R.id.server_user) + val serverUri = findViewById<TextInputEditText>(R.id.clone_uri) + + serverUrl.setText(settings.getString("git_remote_server", "")) + serverPort.setText(settings.getString("git_remote_port", "")) + serverUser.setText(settings.getString("git_remote_username", "")) + serverPath.setText(settings.getString("git_remote_location", "")) + + serverUrl.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} + + override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { + if (serverUrl.isFocused) + updateURI() + } + + override fun afterTextChanged(editable: Editable) {} + }) + serverPort.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} + + override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { + if (serverPort.isFocused) + updateURI() + } + + override fun afterTextChanged(editable: Editable) {} + }) + serverUser.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} + + override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { + if (serverUser.isFocused) + updateURI() + } + + override fun afterTextChanged(editable: Editable) {} + }) + serverPath.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} + + override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { + if (serverPath.isFocused) + updateURI() + } + + override fun afterTextChanged(editable: Editable) {} + }) + + serverUri.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} + + override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { + if (serverUri.isFocused) + splitURI() + } + + override fun afterTextChanged(editable: Editable) {} + }) + + if (operationCode == EDIT_SERVER) { + findViewById<View>(R.id.clone_button).visibility = View.INVISIBLE + findViewById<View>(R.id.save_button).visibility = View.VISIBLE + } else { + findViewById<View>(R.id.clone_button).visibility = View.VISIBLE + findViewById<View>(R.id.save_button).visibility = View.INVISIBLE + } + + updateURI() + } + EDIT_GIT_CONFIG -> { + setContentView(R.layout.activity_git_config) + setTitle(R.string.title_activity_git_config) + + showGitConfig() + } + REQUEST_PULL -> syncRepository(REQUEST_PULL) + + REQUEST_PUSH -> syncRepository(REQUEST_PUSH) + + REQUEST_SYNC -> syncRepository(REQUEST_SYNC) + } + } + + /** + * Fills in the server_uri field with the information coming from other fields + */ + private fun updateURI() { + val uri = findViewById<TextInputEditText>(R.id.clone_uri) + val serverUrl = findViewById<TextInputEditText>(R.id.server_url) + val serverPort = findViewById<TextInputEditText>(R.id.server_port) + val serverPath = findViewById<TextInputEditText>(R.id.server_path) + val serverUser = findViewById<TextInputEditText>(R.id.server_user) + + if (uri != null) { + when (protocol) { + "ssh://" -> { + var hostname = (serverUser.text.toString() + + "@" + + serverUrl.text.toString().trim { it <= ' ' } + + ":") + if (serverPort.text.toString() == "22") { + hostname += serverPath.text.toString() + + findViewById<View>(R.id.warn_url).visibility = View.GONE + } else { + val warnUrl = findViewById<AppCompatTextView>(R.id.warn_url) + if (!serverPath.text.toString().matches("/.*".toRegex()) && serverPort.text.toString().isNotEmpty()) { + warnUrl.setText(R.string.warn_malformed_url_port) + warnUrl.visibility = View.VISIBLE + } else { + warnUrl.visibility = View.GONE + } + hostname += serverPort.text.toString() + serverPath.text.toString() + } + + if (hostname != "@:") uri.setText(hostname) + } + "https://" -> { + val hostname = StringBuilder() + hostname.append(serverUrl.text.toString().trim { it <= ' ' }) + + if (serverPort.text.toString() == "443") { + hostname.append(serverPath.text.toString()) + + findViewById<View>(R.id.warn_url).visibility = View.GONE + } else { + hostname.append("/") + hostname.append(serverPort.text.toString()) + .append(serverPath.text.toString()) + } + + if (hostname.toString() != "@/") uri.setText(hostname) + } + else -> { + } + } + + } + } + + /** + * Splits the information in server_uri into the other fields + */ + private fun splitURI() { + val serverUri = findViewById<TextInputEditText>(R.id.clone_uri) + val serverUrl = findViewById<TextInputEditText>(R.id.server_url) + val serverPort = findViewById<TextInputEditText>(R.id.server_port) + val serverPath = findViewById<TextInputEditText>(R.id.server_path) + val serverUser = findViewById<TextInputEditText>(R.id.server_user) + + val uri = serverUri.text.toString() + val pattern = Pattern.compile("(.+)@([\\w\\d.]+):([\\d]+)*(.*)") + val matcher = pattern.matcher(uri) + if (matcher.find()) { + val count = matcher.groupCount() + if (count > 1) { + serverUser.setText(matcher.group(1)) + serverUrl.setText(matcher.group(2)) + } + if (count == 4) { + serverPort.setText(matcher.group(3)) + serverPath.setText(matcher.group(4)) + + val warnUrl = findViewById<AppCompatTextView>(R.id.warn_url) + if (!serverPath.text.toString().matches("/.*".toRegex()) && serverPort.text.toString().isNotEmpty()) { + warnUrl.setText(R.string.warn_malformed_url_port) + warnUrl.visibility = View.VISIBLE + } else { + warnUrl.visibility = View.GONE + } + } + } + } + + public override fun onResume() { + super.onResume() + updateURI() + } + + override fun onDestroy() { + // Do not leak the service connection + if (identityBuilder != null) { + identityBuilder!!.close() + identityBuilder = null + } + super.onDestroy() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.git_clone, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.user_pref -> try { + val intent = Intent(this, UserPreference::class.java) + startActivity(intent) + return true + } catch (e: Exception) { + println("Exception caught :(") + e.printStackTrace() + } + + android.R.id.home -> { + finish() + return true + } + } + return super.onOptionsItemSelected(item) + } + + /** + * Saves the configuration found in the form + */ + private fun saveConfiguration(): Boolean { + // remember the settings + val editor = settings.edit() + + editor.putString("git_remote_server", (findViewById<View>(R.id.server_url) as TextInputEditText).text.toString()) + editor.putString("git_remote_location", (findViewById<View>(R.id.server_path) as TextInputEditText).text.toString()) + editor.putString("git_remote_username", (findViewById<View>(R.id.server_user) as TextInputEditText).text.toString()) + editor.putString("git_remote_protocol", protocol) + editor.putString("git_remote_auth", connectionMode) + editor.putString("git_remote_port", (findViewById<View>(R.id.server_port) as TextInputEditText).text.toString()) + editor.putString("git_remote_uri", (findViewById<View>(R.id.clone_uri) as TextInputEditText).text.toString()) + + // 'save' hostname variable for use by addRemote() either here or later + // in syncRepository() + hostname = (findViewById<View>(R.id.clone_uri) as TextInputEditText).text.toString() + val port = (findViewById<View>(R.id.server_port) as TextInputEditText).text.toString() + // don't ask the user, take off the protocol that he puts in + hostname = hostname.replaceFirst("^.+://".toRegex(), "") + (findViewById<View>(R.id.clone_uri) as TextInputEditText).setText(hostname) + + if (protocol != "ssh://") { + hostname = protocol + hostname + } else { + // if the port is explicitly given, jgit requires the ssh:// + if (port.isNotEmpty() && port != "22") + hostname = protocol + hostname + + // did he forget the username? + if (!hostname.matches("^.+@.+".toRegex())) { + MaterialAlertDialogBuilder(this) + .setMessage(context.getString(R.string.forget_username_dialog_text)) + .setPositiveButton(context.getString(R.string.dialog_oops), null) + .show() + return false + } + } + if (PasswordRepository.isInitialized && settings.getBoolean("repository_initialized", false)) { + // don't just use the clone_uri text, need to use hostname which has + // had the proper protocol prepended + PasswordRepository.addRemote("origin", hostname, true) + } + + editor.apply() + return true + } + + /** + * Save the repository information to the shared preferences settings + */ + @Suppress("UNUSED_PARAMETER") + fun saveConfiguration(view: View) { + if (!saveConfiguration()) + return + finish() + } + + private fun showGitConfig() { + // init the server information + val username = findViewById<TextInputEditText>(R.id.git_user_name) + val email = findViewById<TextInputEditText>(R.id.git_user_email) + val abort = findViewById<MaterialButton>(R.id.git_abort_rebase) + + username.setText(settings.getString("git_config_user_name", "")) + email.setText(settings.getString("git_config_user_email", "")) + + // git status + val repo = PasswordRepository.getRepository(PasswordRepository.getRepositoryDirectory(context)) + if (repo != null) { + val commitHash = findViewById<AppCompatTextView>(R.id.git_commit_hash) + try { + val objectId = repo.resolve(Constants.HEAD) + val ref = repo.getRef("refs/heads/master") + val head = if (ref.objectId.equals(objectId)) ref.name else "DETACHED" + commitHash.text = String.format("%s (%s)", objectId.abbreviate(8).name(), head) + + // enable the abort button only if we're rebasing + val isRebasing = repo.repositoryState.isRebasing + abort.isEnabled = isRebasing + abort.alpha = if (isRebasing) 1.0f else 0.5f + } catch (e: Exception) { + // ignore + } + + } + } + + private fun saveGitConfigs(): Boolean { + // remember the settings + val editor = settings.edit() + + val email = (findViewById<View>(R.id.git_user_email) as TextInputEditText).text!!.toString() + editor.putString("git_config_user_email", email) + editor.putString("git_config_user_name", (findViewById<View>(R.id.git_user_name) as TextInputEditText).text.toString()) + + if (!email.matches(emailPattern.toRegex())) { + MaterialAlertDialogBuilder(this) + .setMessage(context.getString(R.string.invalid_email_dialog_text)) + .setPositiveButton(context.getString(R.string.dialog_oops), null) + .show() + return false + } + + editor.apply() + return true + } + + @Suppress("UNUSED_PARAMETER") + fun applyGitConfigs(view: View) { + if (!saveGitConfigs()) + return + PasswordRepository.setUserName(settings.getString("git_config_user_name", null) ?: "") + PasswordRepository.setUserEmail(settings.getString("git_config_user_email", null) ?: "") + finish() + } + + @Suppress("UNUSED_PARAMETER") + fun abortRebase(view: View) { + launchGitOperation(BREAK_OUT_OF_DETACHED) + } + + @Suppress("UNUSED_PARAMETER") + fun resetToRemote(view: View) { + launchGitOperation(REQUEST_RESET) + } + + /** + * Clones the repository, the directory exists, deletes it + */ + @Suppress("UNUSED_PARAMETER") + fun cloneRepository(view: View) { + if (PasswordRepository.getRepository(null) == null) { + PasswordRepository.initialize(this) + } + val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(context)) + + if (!saveConfiguration()) + return + + // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder + if (localDir.exists() && localDir.listFiles()!!.isNotEmpty() + && !(localDir.listFiles()!!.size == 1 && localDir.listFiles()!![0].name == ".git")) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg) + " " + localDir.toString()) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete + ) { dialog, _ -> + try { + FileUtils.deleteDirectory(localDir) + launchGitOperation(REQUEST_CLONE) + } catch (e: IOException) { + //TODO Handle the exception correctly if we are unable to delete the directory... + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + + dialog.cancel() + } + .setNegativeButton(R.string.dialog_do_not_delete + ) { dialog, _ -> dialog.cancel() } + .show() + } else { + try { + // Silently delete & replace the lone .git folder if it exists + if (localDir.exists() && localDir.listFiles()!!.size == 1 && localDir.listFiles()!![0].name == ".git") { + try { + FileUtils.deleteDirectory(localDir) + } catch (e: IOException) { + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + + } + } catch (e: Exception) { + //This is what happens when jgit fails :( + //TODO Handle the diffent cases of exceptions + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + + launchGitOperation(REQUEST_CLONE) + } + } + + /** + * Syncs the local repository with the remote one (either pull or push) + * + * @param operation the operation to execute can be REQUEST_PULL or REQUEST_PUSH + */ + private fun syncRepository(operation: Int) { + if (settings.getString("git_remote_username", "")!!.isEmpty() || + settings.getString("git_remote_server", "")!!.isEmpty() || + settings.getString("git_remote_location", "")!!.isEmpty()) + MaterialAlertDialogBuilder(this) + .setMessage(context.getString(R.string.set_information_dialog_text)) + .setPositiveButton(context.getString(R.string.dialog_positive)) { _, _ -> + val intent = Intent(context, UserPreference::class.java) + startActivityForResult(intent, REQUEST_PULL) + } + .setNegativeButton(context.getString(R.string.dialog_negative)) { _, _ -> + // do nothing :( + setResult(AppCompatActivity.RESULT_OK) + finish() + } + .show() + else { + // check that the remote origin is here, else add it + PasswordRepository.addRemote("origin", hostname, false) + launchGitOperation(operation) + } + } + + /** + * Attempt to launch the requested GIT operation. Depending on the configured auth, it may not + * be possible to launch the operation immediately. In that case, this function may launch an + * intermediate activity instead, which will gather necessary information and post it back via + * onActivityResult, which will then re-call this function. This may happen multiple times, + * until either an error is encountered or the operation is successfully launched. + * + * @param operation The type of GIT operation to launch + */ + private fun launchGitOperation(operation: Int) { + val op: GitOperation + val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(context)) + + try { + + // Before launching the operation with OpenKeychain auth, we need to issue several requests + // to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents, + // we just need to keep calling it until it returns a completed ApiIdentity. + if (connectionMode.equals("OpenKeychain", ignoreCase = true) && identity == null) { + // Lazy initialization of the IdentityBuilder + if (identityBuilder == null) { + identityBuilder = SshApiSessionFactory.IdentityBuilder(this) + } + + // Try to get an ApiIdentity and bail if one is not ready yet. The builder will ensure + // that onActivityResult is called with operation again, which will re-invoke us here + identity = identityBuilder!!.tryBuild(operation) + if (identity == null) + return + } + + when (operation) { + REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> op = CloneOperation(localDir, this).setCommand(hostname) + + REQUEST_PULL -> op = PullOperation(localDir, this).setCommand() + + REQUEST_PUSH -> op = PushOperation(localDir, this).setCommand() + + REQUEST_SYNC -> op = SyncOperation(localDir, this).setCommands() + + BREAK_OUT_OF_DETACHED -> op = BreakOutOfDetached(localDir, this).setCommands() + + REQUEST_RESET -> op = ResetToRemoteOperation(localDir, this).setCommands() + + SshApiSessionFactory.POST_SIGNATURE -> return + + else -> { + Log.e(TAG, "Operation not recognized : $operation") + setResult(AppCompatActivity.RESULT_CANCELED) + finish() + return + } + } + + op.executeAfterAuthentication(connectionMode, + settings.getString("git_remote_username", "git")!!, + File("$filesDir/.ssh_key"), + identity) + } catch (e: Exception) { + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, + data: Intent?) { + + // In addition to the pre-operation-launch series of intents for OpenKeychain auth + // that will pass through here and back to launchGitOperation, there is one + // synchronous operation that happens /after/ the operation has been launched in the + // background thread - the actual signing of the SSH challenge. We pass through the + // completed signature to the ApiIdentity, which will be blocked in the other thread + // waiting for it. + if (requestCode == SshApiSessionFactory.POST_SIGNATURE && identity != null) + identity!!.postSignature(data) + + if (resultCode == AppCompatActivity.RESULT_CANCELED) { + setResult(AppCompatActivity.RESULT_CANCELED) + finish() + } else if (resultCode == AppCompatActivity.RESULT_OK) { + // If an operation has been re-queued via this mechanism, let the + // IdentityBuilder attempt to extract some updated state from the intent before + // trying to re-launch the operation. + if (identityBuilder != null) { + identityBuilder!!.consume(data) + } + launchGitOperation(requestCode) + } + super.onActivityResult(requestCode, resultCode, data) + } + + companion object { + const val REQUEST_PULL = 101 + const val REQUEST_PUSH = 102 + const val REQUEST_CLONE = 103 + const val REQUEST_INIT = 104 + const val EDIT_SERVER = 105 + const val REQUEST_SYNC = 106 + @Suppress("Unused") + const val REQUEST_CREATE = 107 + const val EDIT_GIT_CONFIG = 108 + const val BREAK_OUT_OF_DETACHED = 109 + const val REQUEST_RESET = 110 + private const val TAG = "GitAct" + private const val emailPattern = "^[^@]+@[^@]+$" + } +} |
