mirror of
https://github.com/nextcloud/android.git
synced 2024-12-19 15:33:00 +03:00
Merge remote-tracking branch 'origin/master' into dev
This commit is contained in:
commit
5e4ad3f852
10 changed files with 704 additions and 744 deletions
Binary file not shown.
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 10 KiB |
|
@ -1,478 +0,0 @@
|
||||||
/*
|
|
||||||
* ownCloud Android client application
|
|
||||||
*
|
|
||||||
* @author Bartek Przybylski
|
|
||||||
* @author masensio
|
|
||||||
* @author David A. Velasco
|
|
||||||
* Copyright (C) 2011 Bartek Przybylski
|
|
||||||
* Copyright (C) 2015 ownCloud Inc.
|
|
||||||
* Copyright (C) 2020 Kwon Yuna <yunaghgh@naver.com>
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License version 2,
|
|
||||||
* as published by the Free Software Foundation.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
package com.owncloud.android.ui.activity;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.text.Editable;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.text.TextWatcher;
|
|
||||||
import android.view.KeyEvent;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.Window;
|
|
||||||
import android.view.inputmethod.InputMethodManager;
|
|
||||||
import android.widget.EditText;
|
|
||||||
|
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
|
||||||
import com.nextcloud.client.di.Injectable;
|
|
||||||
import com.nextcloud.client.preferences.AppPreferences;
|
|
||||||
import com.owncloud.android.R;
|
|
||||||
import com.owncloud.android.authentication.PassCodeManager;
|
|
||||||
import com.owncloud.android.databinding.PasscodelockBinding;
|
|
||||||
import com.owncloud.android.lib.common.utils.Log_OC;
|
|
||||||
import com.owncloud.android.ui.components.PassCodeEditText;
|
|
||||||
import com.owncloud.android.utils.theme.ViewThemeUtils;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.VisibleForTesting;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
|
|
||||||
public class PassCodeActivity extends AppCompatActivity implements Injectable {
|
|
||||||
|
|
||||||
private static final String TAG = PassCodeActivity.class.getSimpleName();
|
|
||||||
private static final String KEY_PASSCODE_DIGITS = "PASSCODE_DIGITS";
|
|
||||||
private static final String KEY_CONFIRMING_PASSCODE = "CONFIRMING_PASSCODE";
|
|
||||||
|
|
||||||
public final static String ACTION_REQUEST_WITH_RESULT = "ACTION_REQUEST_WITH_RESULT";
|
|
||||||
public final static String ACTION_CHECK_WITH_RESULT = "ACTION_CHECK_WITH_RESULT";
|
|
||||||
public final static String ACTION_CHECK = "ACTION_CHECK";
|
|
||||||
public final static String KEY_PASSCODE = "KEY_PASSCODE";
|
|
||||||
public final static String KEY_CHECK_RESULT = "KEY_CHECK_RESULT";
|
|
||||||
|
|
||||||
public final static String PREFERENCE_PASSCODE_D = "PrefPinCode";
|
|
||||||
public final static String PREFERENCE_PASSCODE_D1 = "PrefPinCode1";
|
|
||||||
public final static String PREFERENCE_PASSCODE_D2 = "PrefPinCode2";
|
|
||||||
public final static String PREFERENCE_PASSCODE_D3 = "PrefPinCode3";
|
|
||||||
public final static String PREFERENCE_PASSCODE_D4 = "PrefPinCode4";
|
|
||||||
|
|
||||||
@Inject AppPreferences preferences;
|
|
||||||
@Inject PassCodeManager passCodeManager;
|
|
||||||
@Inject ViewThemeUtils viewThemeUtils;
|
|
||||||
private PasscodelockBinding binding;
|
|
||||||
private final PassCodeEditText[] passCodeEditTexts = new PassCodeEditText[4];
|
|
||||||
private String[] passCodeDigits = {"", "", "", ""};
|
|
||||||
private boolean confirmingPassCode;
|
|
||||||
private boolean changed = true; // to control that only one blocks jump
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the activity.
|
|
||||||
* <p>
|
|
||||||
* An intent with a valid ACTION is expected; if none is found, an {@link IllegalArgumentException} will be thrown.
|
|
||||||
*
|
|
||||||
* @param savedInstanceState Previously saved state - irrelevant in this case
|
|
||||||
*/
|
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
binding = PasscodelockBinding.inflate(getLayoutInflater());
|
|
||||||
setContentView(binding.getRoot());
|
|
||||||
|
|
||||||
viewThemeUtils.platform.colorTextButtons(binding.cancel);
|
|
||||||
|
|
||||||
passCodeEditTexts[0] = binding.txt0;
|
|
||||||
passCodeEditTexts[1] = binding.txt1;
|
|
||||||
passCodeEditTexts[2] = binding.txt2;
|
|
||||||
passCodeEditTexts[3] = binding.txt3;
|
|
||||||
|
|
||||||
for (EditText passCodeEditText : passCodeEditTexts) {
|
|
||||||
viewThemeUtils.platform.colorEditText(passCodeEditText);
|
|
||||||
}
|
|
||||||
|
|
||||||
passCodeEditTexts[0].requestFocus();
|
|
||||||
|
|
||||||
Window window = getWindow();
|
|
||||||
if (window != null) {
|
|
||||||
window.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ACTION_CHECK.equals(getIntent().getAction())) {
|
|
||||||
/// this is a pass code request; the user has to input the right value
|
|
||||||
binding.header.setText(R.string.pass_code_enter_pass_code);
|
|
||||||
binding.explanation.setVisibility(View.INVISIBLE);
|
|
||||||
setCancelButtonEnabled(false); // no option to cancel
|
|
||||||
|
|
||||||
showDelay();
|
|
||||||
|
|
||||||
} else if (ACTION_REQUEST_WITH_RESULT.equals(getIntent().getAction())) {
|
|
||||||
if (savedInstanceState != null) {
|
|
||||||
confirmingPassCode = savedInstanceState.getBoolean(PassCodeActivity.KEY_CONFIRMING_PASSCODE);
|
|
||||||
passCodeDigits = savedInstanceState.getStringArray(PassCodeActivity.KEY_PASSCODE_DIGITS);
|
|
||||||
}
|
|
||||||
if (confirmingPassCode) {
|
|
||||||
// the app was in the passcode confirmation
|
|
||||||
requestPassCodeConfirmation();
|
|
||||||
} else {
|
|
||||||
// pass code preference has just been activated in SettingsActivity;
|
|
||||||
// will receive and confirm pass code value
|
|
||||||
binding.header.setText(R.string.pass_code_configure_your_pass_code);
|
|
||||||
|
|
||||||
binding.explanation.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
setCancelButtonEnabled(true);
|
|
||||||
|
|
||||||
} else if (ACTION_CHECK_WITH_RESULT.equals(getIntent().getAction())) {
|
|
||||||
// pass code preference has just been disabled in SettingsActivity;
|
|
||||||
// will confirm user knows pass code, then remove it
|
|
||||||
binding.header.setText(R.string.pass_code_remove_your_pass_code);
|
|
||||||
binding.explanation.setVisibility(View.INVISIBLE);
|
|
||||||
setCancelButtonEnabled(true);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new IllegalArgumentException("A valid ACTION is needed in the Intent passed to " + TAG);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTextListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enables or disables the cancel button to allow the user interrupt the ACTION requested to the activity.
|
|
||||||
*
|
|
||||||
* @param enabled 'True' makes the cancel button available, 'false' hides it.
|
|
||||||
*/
|
|
||||||
protected void setCancelButtonEnabled(boolean enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
binding.cancel.setVisibility(View.VISIBLE);
|
|
||||||
binding.cancel.setOnClickListener(v -> finish());
|
|
||||||
} else {
|
|
||||||
binding.cancel.setVisibility(View.INVISIBLE);
|
|
||||||
binding.cancel.setOnClickListener(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
public PasscodelockBinding getBinding() {
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the appropriate listeners to the input boxes receiving each digit of the pass code.
|
|
||||||
*/
|
|
||||||
protected void setTextListeners() {
|
|
||||||
for (int i = 0; i < passCodeEditTexts.length; i++) {
|
|
||||||
final PassCodeEditText editText = passCodeEditTexts[i];
|
|
||||||
boolean isLast = (i == 3);
|
|
||||||
|
|
||||||
editText.addTextChangedListener(new PassCodeDigitTextWatcher(i, isLast));
|
|
||||||
if (i > 0) {
|
|
||||||
setOnKeyListener(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
int finalIndex = i;
|
|
||||||
editText.setOnFocusChangeListener((v, hasFocus) -> onPassCodeEditTextFocusChange(finalIndex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onPassCodeEditTextFocusChange(final int passCodeIndex) {
|
|
||||||
for (int i = 0; i < passCodeIndex; i++) {
|
|
||||||
if (TextUtils.isEmpty(passCodeEditTexts[i].getText())) {
|
|
||||||
passCodeEditTexts[i].requestFocus();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setOnKeyListener(final int passCodeIndex) {
|
|
||||||
passCodeEditTexts[passCodeIndex].setOnKeyListener((v, keyCode, event) -> {
|
|
||||||
if (keyCode == KeyEvent.KEYCODE_DEL && changed) {
|
|
||||||
passCodeEditTexts[passCodeIndex - 1].requestFocus();
|
|
||||||
if (!confirmingPassCode) {
|
|
||||||
passCodeDigits[passCodeIndex - 1] = "";
|
|
||||||
}
|
|
||||||
passCodeEditTexts[passCodeIndex - 1].setText("");
|
|
||||||
changed = false;
|
|
||||||
|
|
||||||
} else if (!changed) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes the pass code entered by the user just after the last digit was in.
|
|
||||||
* <p>
|
|
||||||
* Takes into account the action requested to the activity, the currently saved pass code and the previously typed
|
|
||||||
* pass code, if any.
|
|
||||||
*/
|
|
||||||
private void processFullPassCode() {
|
|
||||||
if (ACTION_CHECK.equals(getIntent().getAction())) {
|
|
||||||
if (checkPassCode()) {
|
|
||||||
preferences.resetPinWrongAttempts();
|
|
||||||
|
|
||||||
/// pass code accepted in request, user is allowed to access the app
|
|
||||||
passCodeManager.updateLockTimestamp();
|
|
||||||
hideSoftKeyboard();
|
|
||||||
finish();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
preferences.increasePinWrongAttempts();
|
|
||||||
|
|
||||||
showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (ACTION_CHECK_WITH_RESULT.equals(getIntent().getAction())) {
|
|
||||||
if (checkPassCode()) {
|
|
||||||
passCodeManager.updateLockTimestamp();
|
|
||||||
Intent resultIntent = new Intent();
|
|
||||||
resultIntent.putExtra(KEY_CHECK_RESULT, true);
|
|
||||||
setResult(RESULT_OK, resultIntent);
|
|
||||||
hideSoftKeyboard();
|
|
||||||
finish();
|
|
||||||
} else {
|
|
||||||
showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (ACTION_REQUEST_WITH_RESULT.equals(getIntent().getAction())) {
|
|
||||||
/// enabling pass code
|
|
||||||
if (!confirmingPassCode) {
|
|
||||||
requestPassCodeConfirmation();
|
|
||||||
|
|
||||||
} else if (confirmPassCode()) {
|
|
||||||
/// confirmed: user typed the same pass code twice
|
|
||||||
savePassCodeAndExit();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
showErrorAndRestart(R.string.pass_code_mismatch, R.string.pass_code_configure_your_pass_code, View.VISIBLE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void hideSoftKeyboard() {
|
|
||||||
View focusedView = getCurrentFocus();
|
|
||||||
if (focusedView != null) {
|
|
||||||
InputMethodManager inputMethodManager =
|
|
||||||
(InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
|
|
||||||
inputMethodManager.hideSoftInputFromWindow(
|
|
||||||
focusedView.getWindowToken(),
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showErrorAndRestart(int errorMessage, int headerMessage, int explanationVisibility) {
|
|
||||||
Arrays.fill(passCodeDigits, null);
|
|
||||||
Snackbar.make(findViewById(android.R.id.content), getString(errorMessage), Snackbar.LENGTH_LONG).show();
|
|
||||||
binding.header.setText(headerMessage); // TODO check if really needed
|
|
||||||
binding.explanation.setVisibility(explanationVisibility); // TODO check if really needed
|
|
||||||
clearBoxes();
|
|
||||||
|
|
||||||
showDelay();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask to the user for retyping the pass code just entered before saving it as the current pass code.
|
|
||||||
*/
|
|
||||||
protected void requestPassCodeConfirmation() {
|
|
||||||
clearBoxes();
|
|
||||||
binding.header.setText(R.string.pass_code_reenter_your_pass_code);
|
|
||||||
binding.explanation.setVisibility(View.INVISIBLE);
|
|
||||||
confirmingPassCode = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares pass code entered by the user with the value currently saved in the app.
|
|
||||||
*
|
|
||||||
* @return 'True' if entered pass code equals to the saved one.
|
|
||||||
*/
|
|
||||||
protected boolean checkPassCode() {
|
|
||||||
String[] savedPassCodeDigits = preferences.getPassCode();
|
|
||||||
|
|
||||||
boolean result = true;
|
|
||||||
for (int i = 0; i < passCodeDigits.length && result; i++) {
|
|
||||||
result = passCodeDigits[i] != null && passCodeDigits[i].equals(savedPassCodeDigits[i]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares pass code retyped by the user in the input fields with the value entered just before.
|
|
||||||
*
|
|
||||||
* @return 'True' if retyped pass code equals to the entered before.
|
|
||||||
*/
|
|
||||||
protected boolean confirmPassCode() {
|
|
||||||
confirmingPassCode = false;
|
|
||||||
|
|
||||||
for (int i = 0; i < passCodeEditTexts.length; i++) {
|
|
||||||
Editable passCodeText = passCodeEditTexts[i].getText();
|
|
||||||
if (passCodeText == null || !passCodeText.toString().equals(passCodeDigits[i])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the input fields to empty strings and puts the focus on the first one.
|
|
||||||
*/
|
|
||||||
protected void clearBoxes() {
|
|
||||||
for (EditText mPassCodeEditText : passCodeEditTexts) {
|
|
||||||
mPassCodeEditText.setText("");
|
|
||||||
}
|
|
||||||
passCodeEditTexts[0].requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overrides click on the BACK arrow to correctly cancel ACTION_ENABLE or ACTION_DISABLE, while preventing than
|
|
||||||
* ACTION_CHECK may be worked around.
|
|
||||||
*
|
|
||||||
* @param keyCode Key code of the key that triggered the down event.
|
|
||||||
* @param event Event triggered.
|
|
||||||
* @return 'True' when the key event was processed by this method.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
|
||||||
if (keyCode == KeyEvent.KEYCODE_BACK && event.getRepeatCount() == 0) {
|
|
||||||
if (ACTION_CHECK.equals(getIntent().getAction())) {
|
|
||||||
moveTaskToBack(true);
|
|
||||||
finishAndRemoveTask();
|
|
||||||
} else if (ACTION_REQUEST_WITH_RESULT.equals(getIntent().getAction()) ||
|
|
||||||
ACTION_CHECK_WITH_RESULT.equals(getIntent().getAction())) {
|
|
||||||
finish();
|
|
||||||
}// else, do nothing, but report that the key was consumed to stay alive
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return super.onKeyDown(keyCode, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the pass code input by the user as the current pass code.
|
|
||||||
*/
|
|
||||||
protected void savePassCodeAndExit() {
|
|
||||||
Intent resultIntent = new Intent();
|
|
||||||
resultIntent.putExtra(KEY_PASSCODE,
|
|
||||||
passCodeDigits[0] + passCodeDigits[1] + passCodeDigits[2] + passCodeDigits[3]);
|
|
||||||
|
|
||||||
setResult(RESULT_OK, resultIntent);
|
|
||||||
|
|
||||||
passCodeManager.updateLockTimestamp();
|
|
||||||
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showDelay() {
|
|
||||||
int delay = preferences.pinBruteForceDelay();
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
binding.explanation.setText(R.string.brute_force_delay);
|
|
||||||
binding.explanation.setVisibility(View.VISIBLE);
|
|
||||||
binding.txt0.setEnabled(false);
|
|
||||||
binding.txt1.setEnabled(false);
|
|
||||||
binding.txt2.setEnabled(false);
|
|
||||||
binding.txt3.setEnabled(false);
|
|
||||||
|
|
||||||
new Thread(new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
Thread.sleep(delay * 1000L);
|
|
||||||
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
binding.explanation.setVisibility(View.INVISIBLE);
|
|
||||||
binding.txt0.setEnabled(true);
|
|
||||||
binding.txt1.setEnabled(true);
|
|
||||||
binding.txt2.setEnabled(true);
|
|
||||||
binding.txt3.setEnabled(true);
|
|
||||||
});
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Log_OC.e(this, "Could not delay password input prompt");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
|
||||||
super.onSaveInstanceState(outState);
|
|
||||||
outState.putBoolean(PassCodeActivity.KEY_CONFIRMING_PASSCODE, confirmingPassCode);
|
|
||||||
outState.putStringArray(PassCodeActivity.KEY_PASSCODE_DIGITS, passCodeDigits);
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PassCodeDigitTextWatcher implements TextWatcher {
|
|
||||||
|
|
||||||
private int mIndex = -1;
|
|
||||||
private boolean mLastOne;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor
|
|
||||||
*
|
|
||||||
* @param index Position in the pass code of the input field that will be bound to this watcher.
|
|
||||||
* @param lastOne 'True' means that watcher corresponds to the last position of the pass code.
|
|
||||||
*/
|
|
||||||
PassCodeDigitTextWatcher(int index, boolean lastOne) {
|
|
||||||
mIndex = index;
|
|
||||||
mLastOne = lastOne;
|
|
||||||
|
|
||||||
if (mIndex < 0) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Invalid index in " + PassCodeDigitTextWatcher.class.getSimpleName() +
|
|
||||||
" constructor"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int next() {
|
|
||||||
return mLastOne ? 0 : mIndex + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs several actions when the user types a digit in an input field: - saves the input digit to the state
|
|
||||||
* of the activity; this will allow retyping the pass code to confirm it. - moves the focus automatically to the
|
|
||||||
* next field - for the last field, triggers the processing of the full pass code
|
|
||||||
*
|
|
||||||
* @param s Changed text
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void afterTextChanged(Editable s) {
|
|
||||||
if (s.length() > 0) {
|
|
||||||
if (!confirmingPassCode) {
|
|
||||||
Editable passCodeText = passCodeEditTexts[mIndex].getText();
|
|
||||||
|
|
||||||
if (passCodeText != null) {
|
|
||||||
passCodeDigits[mIndex] = passCodeText.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mLastOne) {
|
|
||||||
processFullPassCode();
|
|
||||||
} else {
|
|
||||||
passCodeEditTexts[next()].requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
Log_OC.d(TAG, "Text box " + mIndex + " was cleaned");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,437 @@
|
||||||
|
/*
|
||||||
|
* ownCloud Android client application
|
||||||
|
*
|
||||||
|
* @author Bartek Przybylski
|
||||||
|
* @author masensio
|
||||||
|
* @author David A. Velasco
|
||||||
|
* Copyright (C) 2011 Bartek Przybylski
|
||||||
|
* Copyright (C) 2015 ownCloud Inc.
|
||||||
|
* Copyright (C) 2020 Kwon Yuna <yunaghgh@naver.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License version 2,
|
||||||
|
* as published by the Free Software Foundation.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
package com.owncloud.android.ui.activity
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||||
|
import com.nextcloud.client.di.Injectable
|
||||||
|
import com.nextcloud.client.preferences.AppPreferences
|
||||||
|
import com.owncloud.android.R
|
||||||
|
import com.owncloud.android.authentication.PassCodeManager
|
||||||
|
import com.owncloud.android.databinding.PasscodelockBinding
|
||||||
|
import com.owncloud.android.lib.common.utils.Log_OC
|
||||||
|
import com.owncloud.android.ui.components.PassCodeEditText
|
||||||
|
import com.owncloud.android.utils.theme.ViewThemeUtils
|
||||||
|
import java.util.Arrays
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Suppress("TooManyFunctions", "MagicNumber")
|
||||||
|
class PassCodeActivity : AppCompatActivity(), Injectable {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = PassCodeActivity::class.java.simpleName
|
||||||
|
|
||||||
|
private const val KEY_PASSCODE_DIGITS = "PASSCODE_DIGITS"
|
||||||
|
private const val KEY_CONFIRMING_PASSCODE = "CONFIRMING_PASSCODE"
|
||||||
|
const val ACTION_REQUEST_WITH_RESULT = "ACTION_REQUEST_WITH_RESULT"
|
||||||
|
const val ACTION_CHECK_WITH_RESULT = "ACTION_CHECK_WITH_RESULT"
|
||||||
|
const val ACTION_CHECK = "ACTION_CHECK"
|
||||||
|
const val KEY_PASSCODE = "KEY_PASSCODE"
|
||||||
|
const val KEY_CHECK_RESULT = "KEY_CHECK_RESULT"
|
||||||
|
const val PREFERENCE_PASSCODE_D = "PrefPinCode"
|
||||||
|
const val PREFERENCE_PASSCODE_D1 = "PrefPinCode1"
|
||||||
|
const val PREFERENCE_PASSCODE_D2 = "PrefPinCode2"
|
||||||
|
const val PREFERENCE_PASSCODE_D3 = "PrefPinCode3"
|
||||||
|
const val PREFERENCE_PASSCODE_D4 = "PrefPinCode4"
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var preferences: AppPreferences? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var passCodeManager: PassCodeManager? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var viewThemeUtils: ViewThemeUtils? = null
|
||||||
|
|
||||||
|
@get:VisibleForTesting
|
||||||
|
lateinit var binding: PasscodelockBinding
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val passCodeEditTexts = arrayOfNulls<PassCodeEditText>(4)
|
||||||
|
private var passCodeDigits: Array<String?>? = arrayOf("", "", "", "")
|
||||||
|
private var confirmingPassCode = false
|
||||||
|
private var changed = true // to control that only one blocks jump
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = PasscodelockBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
applyTint()
|
||||||
|
setupPasscodeEditTexts()
|
||||||
|
setSoftInputMode()
|
||||||
|
setupUI(savedInstanceState)
|
||||||
|
setTextListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyTint() {
|
||||||
|
viewThemeUtils?.platform?.colorViewBackground(binding.cardViewContent, ColorRole.SURFACE_VARIANT)
|
||||||
|
viewThemeUtils?.material?.colorMaterialButtonPrimaryBorderless(binding.cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPasscodeEditTexts() {
|
||||||
|
passCodeEditTexts[0] = binding.txt0
|
||||||
|
passCodeEditTexts[1] = binding.txt1
|
||||||
|
passCodeEditTexts[2] = binding.txt2
|
||||||
|
passCodeEditTexts[3] = binding.txt3
|
||||||
|
|
||||||
|
passCodeEditTexts.forEach {
|
||||||
|
it?.let { viewThemeUtils?.platform?.colorEditText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
passCodeEditTexts[0]?.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setSoftInputMode() {
|
||||||
|
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupUI(savedInstanceState: Bundle?) {
|
||||||
|
if (ACTION_CHECK == intent.action) {
|
||||||
|
// / this is a pass code request; the user has to input the right value
|
||||||
|
binding.header.setText(R.string.pass_code_enter_pass_code)
|
||||||
|
binding.explanation.visibility = View.INVISIBLE
|
||||||
|
setCancelButtonEnabled(false) // no option to cancel
|
||||||
|
showDelay()
|
||||||
|
} else if (ACTION_REQUEST_WITH_RESULT == intent.action) {
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
confirmingPassCode = savedInstanceState.getBoolean(KEY_CONFIRMING_PASSCODE)
|
||||||
|
passCodeDigits = savedInstanceState.getStringArray(KEY_PASSCODE_DIGITS)
|
||||||
|
}
|
||||||
|
if (confirmingPassCode) {
|
||||||
|
// the app was in the passcode confirmation
|
||||||
|
requestPassCodeConfirmation()
|
||||||
|
} else {
|
||||||
|
// pass code preference has just been activated in SettingsActivity;
|
||||||
|
// will receive and confirm pass code value
|
||||||
|
binding.header.setText(R.string.pass_code_configure_your_pass_code)
|
||||||
|
binding.explanation.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
setCancelButtonEnabled(true)
|
||||||
|
} else if (ACTION_CHECK_WITH_RESULT == intent.action) {
|
||||||
|
// pass code preference has just been disabled in SettingsActivity;
|
||||||
|
// will confirm user knows pass code, then remove it
|
||||||
|
binding.header.setText(R.string.pass_code_remove_your_pass_code)
|
||||||
|
binding.explanation.visibility = View.INVISIBLE
|
||||||
|
setCancelButtonEnabled(true)
|
||||||
|
} else {
|
||||||
|
throw IllegalArgumentException("A valid ACTION is needed in the Intent passed to $TAG")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCancelButtonEnabled(enabled: Boolean) {
|
||||||
|
binding.cancel.visibility = if (enabled) {
|
||||||
|
View.VISIBLE
|
||||||
|
} else {
|
||||||
|
View.INVISIBLE
|
||||||
|
}
|
||||||
|
binding.cancel.setOnClickListener {
|
||||||
|
if (enabled) {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setTextListeners() {
|
||||||
|
for (i in passCodeEditTexts.indices) {
|
||||||
|
val editText = passCodeEditTexts[i]
|
||||||
|
val isLast = (i == 3)
|
||||||
|
|
||||||
|
editText?.addTextChangedListener(PassCodeDigitTextWatcher(i, isLast))
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
setOnKeyListener(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
editText?.onFocusChangeListener = View.OnFocusChangeListener { _: View?, _: Boolean ->
|
||||||
|
onPassCodeEditTextFocusChange(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPassCodeEditTextFocusChange(passCodeIndex: Int) {
|
||||||
|
for (i in 0 until passCodeIndex) {
|
||||||
|
if (TextUtils.isEmpty(passCodeEditTexts[i]?.text)) {
|
||||||
|
passCodeEditTexts[i]?.requestFocus()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setOnKeyListener(passCodeIndex: Int) {
|
||||||
|
passCodeEditTexts[passCodeIndex]?.setOnKeyListener { _: View?, keyCode: Int, _: KeyEvent? ->
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_DEL && changed) {
|
||||||
|
passCodeEditTexts[passCodeIndex - 1]?.requestFocus()
|
||||||
|
|
||||||
|
if (!confirmingPassCode) {
|
||||||
|
passCodeDigits?.set(passCodeIndex - 1, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
passCodeEditTexts[passCodeIndex - 1]?.setText("")
|
||||||
|
|
||||||
|
changed = false
|
||||||
|
} else if (!changed) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the pass code entered by the user just after the last digit was in.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Takes into account the action requested to the activity, the currently saved pass code and the previously typed
|
||||||
|
* pass code, if any.
|
||||||
|
*/
|
||||||
|
private fun processFullPassCode() {
|
||||||
|
if (ACTION_CHECK == intent.action) {
|
||||||
|
if (checkPassCode()) {
|
||||||
|
preferences?.resetPinWrongAttempts()
|
||||||
|
|
||||||
|
// / pass code accepted in request, user is allowed to access the app
|
||||||
|
passCodeManager?.updateLockTimestamp()
|
||||||
|
hideSoftKeyboard()
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
preferences?.increasePinWrongAttempts()
|
||||||
|
showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE)
|
||||||
|
}
|
||||||
|
} else if (ACTION_CHECK_WITH_RESULT == intent.action) {
|
||||||
|
if (checkPassCode()) {
|
||||||
|
passCodeManager?.updateLockTimestamp()
|
||||||
|
|
||||||
|
val resultIntent = Intent()
|
||||||
|
resultIntent.putExtra(KEY_CHECK_RESULT, true)
|
||||||
|
setResult(RESULT_OK, resultIntent)
|
||||||
|
hideSoftKeyboard()
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE)
|
||||||
|
}
|
||||||
|
} else if (ACTION_REQUEST_WITH_RESULT == intent.action) {
|
||||||
|
// / enabling pass code
|
||||||
|
if (!confirmingPassCode) {
|
||||||
|
requestPassCodeConfirmation()
|
||||||
|
} else if (confirmPassCode()) {
|
||||||
|
// / confirmed: user typed the same pass code twice
|
||||||
|
savePassCodeAndExit()
|
||||||
|
} else {
|
||||||
|
showErrorAndRestart(
|
||||||
|
R.string.pass_code_mismatch,
|
||||||
|
R.string.pass_code_configure_your_pass_code,
|
||||||
|
View.VISIBLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideSoftKeyboard() {
|
||||||
|
currentFocus?.let {
|
||||||
|
val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
inputMethodManager.hideSoftInputFromWindow(
|
||||||
|
it.windowToken,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showErrorAndRestart(errorMessage: Int, headerMessage: Int, explanationVisibility: Int) {
|
||||||
|
passCodeDigits?.let { Arrays.fill(it, null) }
|
||||||
|
|
||||||
|
Snackbar.make(findViewById(android.R.id.content), getString(errorMessage), Snackbar.LENGTH_LONG).show()
|
||||||
|
binding.header.setText(headerMessage) // TODO check if really needed
|
||||||
|
binding.explanation.visibility = explanationVisibility // TODO check if really needed
|
||||||
|
clearBoxes()
|
||||||
|
showDelay()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask to the user for retyping the pass code just entered before saving it as the current pass code.
|
||||||
|
*/
|
||||||
|
private fun requestPassCodeConfirmation() {
|
||||||
|
clearBoxes()
|
||||||
|
binding.header.setText(R.string.pass_code_reenter_your_pass_code)
|
||||||
|
binding.explanation.visibility = View.INVISIBLE
|
||||||
|
confirmingPassCode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPassCode(): Boolean {
|
||||||
|
val savedPassCodeDigits = preferences?.passCode
|
||||||
|
return passCodeDigits?.zip(savedPassCodeDigits.orEmpty()) { input, saved ->
|
||||||
|
input != null && input == saved
|
||||||
|
}?.all { it } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmPassCode(): Boolean {
|
||||||
|
return passCodeEditTexts.indices.all { i ->
|
||||||
|
passCodeEditTexts[i]?.text.toString() == passCodeDigits!![i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearBoxes() {
|
||||||
|
passCodeEditTexts.forEach { it?.text?.clear() }
|
||||||
|
passCodeEditTexts.firstOrNull()?.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides click on the BACK arrow to correctly cancel ACTION_ENABLE or ACTION_DISABLE, while preventing than
|
||||||
|
* ACTION_CHECK may be worked around.
|
||||||
|
*
|
||||||
|
* @param keyCode Key code of the key that triggered the down event.
|
||||||
|
* @param event Event triggered.
|
||||||
|
* @return 'True' when the key event was processed by this method.
|
||||||
|
*/
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_BACK && event.repeatCount == 0) {
|
||||||
|
if (ACTION_CHECK == intent.action) {
|
||||||
|
moveTaskToBack(true)
|
||||||
|
finishAndRemoveTask()
|
||||||
|
} else if (ACTION_REQUEST_WITH_RESULT == intent.action || ACTION_CHECK_WITH_RESULT == intent.action) {
|
||||||
|
finish()
|
||||||
|
} // else, do nothing, but report that the key was consumed to stay alive
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savePassCodeAndExit() {
|
||||||
|
val resultIntent = Intent()
|
||||||
|
resultIntent.putExtra(
|
||||||
|
KEY_PASSCODE,
|
||||||
|
passCodeDigits!![0] + passCodeDigits!![1] + passCodeDigits!![2] + passCodeDigits!![3]
|
||||||
|
)
|
||||||
|
setResult(RESULT_OK, resultIntent)
|
||||||
|
passCodeManager?.updateLockTimestamp()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDelay() {
|
||||||
|
val delay = preferences?.pinBruteForceDelay() ?: 0
|
||||||
|
|
||||||
|
if (delay <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.explanation.setText(R.string.brute_force_delay)
|
||||||
|
binding.explanation.visibility = View.VISIBLE
|
||||||
|
binding.txt0.isEnabled = false
|
||||||
|
binding.txt1.isEnabled = false
|
||||||
|
binding.txt2.isEnabled = false
|
||||||
|
binding.txt3.isEnabled = false
|
||||||
|
|
||||||
|
Thread(object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
try {
|
||||||
|
Thread.sleep(delay * 1000L)
|
||||||
|
|
||||||
|
runOnUiThread {
|
||||||
|
binding.explanation.visibility = View.INVISIBLE
|
||||||
|
binding.txt0.isEnabled = true
|
||||||
|
binding.txt1.isEnabled = true
|
||||||
|
binding.txt2.isEnabled = true
|
||||||
|
binding.txt3.isEnabled = true
|
||||||
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
Log_OC.e(this, "Could not delay password input prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putBoolean(KEY_CONFIRMING_PASSCODE, confirmingPassCode)
|
||||||
|
outState.putStringArray(KEY_PASSCODE_DIGITS, passCodeDigits)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class PassCodeDigitTextWatcher(index: Int, lastOne: Boolean) : TextWatcher {
|
||||||
|
private var mIndex = -1
|
||||||
|
private val mLastOne: Boolean
|
||||||
|
|
||||||
|
init {
|
||||||
|
mIndex = index
|
||||||
|
mLastOne = lastOne
|
||||||
|
|
||||||
|
require(mIndex >= 0) {
|
||||||
|
"Invalid index in " + PassCodeDigitTextWatcher::class.java.simpleName +
|
||||||
|
" constructor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private operator fun next(): Int {
|
||||||
|
return if (mLastOne) 0 else mIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs several actions when the user types a digit in an input field: - saves the input digit to the state
|
||||||
|
* of the activity; this will allow retyping the pass code to confirm it. - moves the focus automatically to the
|
||||||
|
* next field - for the last field, triggers the processing of the full pass code
|
||||||
|
*
|
||||||
|
* @param s Changed text
|
||||||
|
*/
|
||||||
|
override fun afterTextChanged(s: Editable) {
|
||||||
|
if (s.isNotEmpty()) {
|
||||||
|
if (!confirmingPassCode) {
|
||||||
|
val passCodeText = passCodeEditTexts[mIndex]?.text
|
||||||
|
|
||||||
|
if (passCodeText != null) {
|
||||||
|
passCodeDigits!![mIndex] = passCodeText.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mLastOne) {
|
||||||
|
processFullPassCode()
|
||||||
|
} else {
|
||||||
|
passCodeEditTexts[next()]?.requestFocus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log_OC.d(TAG, "Text box $mIndex was cleaned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("EmptyFunctionBlock")
|
||||||
|
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("EmptyFunctionBlock")
|
||||||
|
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,254 +0,0 @@
|
||||||
/*
|
|
||||||
* Nextcloud Android client application
|
|
||||||
*
|
|
||||||
* @author Tobias Kaminsky
|
|
||||||
* @author Chris Narkiewicz
|
|
||||||
*
|
|
||||||
* Copyright (C) 2018 Tobias Kaminsky
|
|
||||||
* Copyright (C) 2018 Nextcloud GmbH.
|
|
||||||
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.owncloud.android.ui.activity;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.view.KeyEvent;
|
|
||||||
import android.webkit.JavascriptInterface;
|
|
||||||
|
|
||||||
import com.nextcloud.client.account.CurrentAccountProvider;
|
|
||||||
import com.nextcloud.client.account.User;
|
|
||||||
import com.nextcloud.client.network.ClientFactory;
|
|
||||||
import com.owncloud.android.R;
|
|
||||||
import com.owncloud.android.datamodel.OCFile;
|
|
||||||
import com.owncloud.android.lib.common.OwnCloudAccount;
|
|
||||||
import com.owncloud.android.lib.common.operations.RemoteOperationResult;
|
|
||||||
import com.owncloud.android.lib.common.utils.Log_OC;
|
|
||||||
import com.owncloud.android.operations.RichDocumentsCreateAssetOperation;
|
|
||||||
import com.owncloud.android.ui.asynctasks.PrintAsyncTask;
|
|
||||||
import com.owncloud.android.ui.asynctasks.RichDocumentsLoadUrlTask;
|
|
||||||
import com.owncloud.android.ui.fragment.OCFileListFragment;
|
|
||||||
import com.owncloud.android.utils.DisplayUtils;
|
|
||||||
import com.owncloud.android.utils.FileStorageUtils;
|
|
||||||
|
|
||||||
import org.json.JSONException;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.lang.ref.WeakReference;
|
|
||||||
|
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens document for editing via Richdocuments app in a web view
|
|
||||||
*/
|
|
||||||
public class RichDocumentsEditorWebView extends EditorWebView {
|
|
||||||
private static final int REQUEST_REMOTE_FILE = 100;
|
|
||||||
private static final String URL = "URL";
|
|
||||||
private static final String HYPERLINK = "Url";
|
|
||||||
private static final String TYPE = "Type";
|
|
||||||
private static final String PRINT = "print";
|
|
||||||
private static final String SLIDESHOW = "slideshow";
|
|
||||||
private static final String NEW_NAME = "NewName";
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
protected CurrentAccountProvider currentAccountProvider;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
protected ClientFactory clientFactory;
|
|
||||||
|
|
||||||
@SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT_INTERFACE")
|
|
||||||
@Override
|
|
||||||
protected void postOnCreate() {
|
|
||||||
super.postOnCreate();
|
|
||||||
|
|
||||||
getWebView().addJavascriptInterface(new RichDocumentsMobileInterface(), "RichDocumentsMobileInterface");
|
|
||||||
|
|
||||||
// load url in background
|
|
||||||
loadUrl(getIntent().getStringExtra(EXTRA_URL));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onNewIntent(Intent intent) {
|
|
||||||
super.onNewIntent(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openFileChooser() {
|
|
||||||
Intent action = new Intent(this, FilePickerActivity.class);
|
|
||||||
action.putExtra(OCFileListFragment.ARG_MIMETYPE, "image/");
|
|
||||||
startActivityForResult(action, REQUEST_REMOTE_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void handleActivityResult(int requestCode, int resultCode, Intent data) {
|
|
||||||
switch (requestCode) {
|
|
||||||
case REQUEST_REMOTE_FILE:
|
|
||||||
handleRemoteFile(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// unexpected, do nothing
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
super.handleActivityResult(requestCode, resultCode, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleRemoteFile(Intent data) {
|
|
||||||
OCFile file = data.getParcelableExtra(FolderPickerActivity.EXTRA_FILES);
|
|
||||||
|
|
||||||
new Thread(() -> {
|
|
||||||
User user = currentAccountProvider.getUser();
|
|
||||||
RichDocumentsCreateAssetOperation operation = new RichDocumentsCreateAssetOperation(file.getRemotePath());
|
|
||||||
RemoteOperationResult result = operation.execute(user, this);
|
|
||||||
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
String asset = (String) result.getSingleData();
|
|
||||||
|
|
||||||
runOnUiThread(() -> getWebView().evaluateJavascript("OCA.RichDocuments.documentsMain.postAsset('" +
|
|
||||||
file.getFileName() + "', '" + asset + "');", null));
|
|
||||||
} else {
|
|
||||||
runOnUiThread(() -> DisplayUtils.showSnackMessage(this, "Inserting image failed!"));
|
|
||||||
}
|
|
||||||
}).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onSaveInstanceState(@NonNull Bundle outState) {
|
|
||||||
outState.putString(EXTRA_URL, url);
|
|
||||||
super.onSaveInstanceState(outState);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
|
|
||||||
url = savedInstanceState.getString(EXTRA_URL);
|
|
||||||
super.onRestoreInstanceState(savedInstanceState);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
|
|
||||||
getWebView().evaluateJavascript("if (typeof OCA.RichDocuments.documentsMain.postGrabFocus !== 'undefined') " +
|
|
||||||
"{ OCA.RichDocuments.documentsMain.postGrabFocus(); }",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void printFile(Uri url) {
|
|
||||||
OwnCloudAccount account = accountManager.getCurrentOwnCloudAccount();
|
|
||||||
|
|
||||||
if (account == null) {
|
|
||||||
DisplayUtils.showSnackMessage(getWebView(), getString(R.string.failed_to_print));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
File targetFile = new File(FileStorageUtils.getTemporalPath(account.getName()) + "/print.pdf");
|
|
||||||
|
|
||||||
new PrintAsyncTask(targetFile, url.toString(), new WeakReference<>(this)).execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void loadUrl(String url) {
|
|
||||||
if (TextUtils.isEmpty(url)) {
|
|
||||||
new RichDocumentsLoadUrlTask(this, getUser().get(), getFile()).execute();
|
|
||||||
} else {
|
|
||||||
super.loadUrl(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showSlideShow(Uri url) {
|
|
||||||
Intent intent = new Intent(this, ExternalSiteWebView.class);
|
|
||||||
intent.putExtra(ExternalSiteWebView.EXTRA_URL, url.toString());
|
|
||||||
intent.putExtra(ExternalSiteWebView.EXTRA_SHOW_SIDEBAR, false);
|
|
||||||
intent.putExtra(ExternalSiteWebView.EXTRA_SHOW_TOOLBAR, false);
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private class RichDocumentsMobileInterface extends MobileInterface {
|
|
||||||
@JavascriptInterface
|
|
||||||
public void insertGraphic() {
|
|
||||||
openFileChooser();
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
public void documentLoaded() {
|
|
||||||
runOnUiThread(RichDocumentsEditorWebView.this::hideLoading);
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
public void downloadAs(String json) {
|
|
||||||
try {
|
|
||||||
JSONObject downloadJson = new JSONObject(json);
|
|
||||||
|
|
||||||
Uri url = Uri.parse(downloadJson.getString(URL));
|
|
||||||
|
|
||||||
switch (downloadJson.getString(TYPE)) {
|
|
||||||
case PRINT:
|
|
||||||
printFile(url);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SLIDESHOW:
|
|
||||||
showSlideShow(url);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
downloadFile(url);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (JSONException e) {
|
|
||||||
Log_OC.e(this, "Failed to parse download json message: " + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
public void fileRename(String renameString) {
|
|
||||||
// when shared file is renamed in another instance, we will get notified about it
|
|
||||||
// need to change filename for sharing
|
|
||||||
try {
|
|
||||||
JSONObject renameJson = new JSONObject(renameString);
|
|
||||||
String newName = renameJson.getString(NEW_NAME);
|
|
||||||
getFile().setFileName(newName);
|
|
||||||
} catch (JSONException e) {
|
|
||||||
Log_OC.e(this, "Failed to parse rename json message: " + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
public void paste() {
|
|
||||||
// Javascript cannot do this by itself, so help out.
|
|
||||||
getWebView().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_PASTE));
|
|
||||||
getWebView().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_PASTE));
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
public void hyperlink(String hyperlink) {
|
|
||||||
try {
|
|
||||||
String url = new JSONObject(hyperlink).getString(HYPERLINK);
|
|
||||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
|
||||||
intent.setData(Uri.parse(url));
|
|
||||||
startActivity(intent);
|
|
||||||
} catch (JSONException e) {
|
|
||||||
Log_OC.e(this, "Failed to parse download json message: " + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,244 @@
|
||||||
|
/*
|
||||||
|
* Nextcloud Android client application
|
||||||
|
*
|
||||||
|
* @author Tobias Kaminsky
|
||||||
|
* @author Chris Narkiewicz
|
||||||
|
*
|
||||||
|
* Copyright (C) 2018 Tobias Kaminsky
|
||||||
|
* Copyright (C) 2018 Nextcloud GmbH.
|
||||||
|
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.owncloud.android.ui.activity
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import com.nextcloud.client.account.CurrentAccountProvider
|
||||||
|
import com.nextcloud.client.network.ClientFactory
|
||||||
|
import com.owncloud.android.R
|
||||||
|
import com.owncloud.android.datamodel.OCFile
|
||||||
|
import com.owncloud.android.lib.common.utils.Log_OC
|
||||||
|
import com.owncloud.android.operations.RichDocumentsCreateAssetOperation
|
||||||
|
import com.owncloud.android.ui.asynctasks.PrintAsyncTask
|
||||||
|
import com.owncloud.android.ui.asynctasks.RichDocumentsLoadUrlTask
|
||||||
|
import com.owncloud.android.ui.fragment.OCFileListFragment
|
||||||
|
import com.owncloud.android.utils.DisplayUtils
|
||||||
|
import com.owncloud.android.utils.FileStorageUtils
|
||||||
|
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens document for editing via Richdocuments app in a web view
|
||||||
|
*/
|
||||||
|
class RichDocumentsEditorWebView : EditorWebView() {
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var currentAccountProvider: CurrentAccountProvider? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var clientFactory: ClientFactory? = null
|
||||||
|
|
||||||
|
private var activityResult: ActivityResultLauncher<Intent>? = null
|
||||||
|
|
||||||
|
@SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT_INTERFACE")
|
||||||
|
override fun postOnCreate() {
|
||||||
|
super.postOnCreate()
|
||||||
|
|
||||||
|
webView.addJavascriptInterface(RichDocumentsMobileInterface(), "RichDocumentsMobileInterface")
|
||||||
|
|
||||||
|
intent.getStringExtra(EXTRA_URL)?.let {
|
||||||
|
loadUrl(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerActivityResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openFileChooser() {
|
||||||
|
val action = Intent(this, FilePickerActivity::class.java)
|
||||||
|
action.putExtra(OCFileListFragment.ARG_MIMETYPE, "image/")
|
||||||
|
activityResult?.launch(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerActivityResult() {
|
||||||
|
activityResult =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||||
|
if (RESULT_OK == result.resultCode) {
|
||||||
|
result.data?.let {
|
||||||
|
handleRemoteFile(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRemoteFile(data: Intent) {
|
||||||
|
val file = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
data.getParcelableExtra(FolderPickerActivity.EXTRA_FILES, OCFile::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
data.getParcelableExtra(FolderPickerActivity.EXTRA_FILES)
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
val user = currentAccountProvider?.user
|
||||||
|
val operation = RichDocumentsCreateAssetOperation(file?.remotePath)
|
||||||
|
val result = operation.execute(user, this)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
val asset = result.singleData as String
|
||||||
|
runOnUiThread {
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"OCA.RichDocuments.documentsMain.postAsset('" +
|
||||||
|
file?.fileName + "', '" + asset + "');",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runOnUiThread { DisplayUtils.showSnackMessage(this, "Inserting image failed!") }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
outState.putString(EXTRA_URL, url)
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
url = savedInstanceState.getString(EXTRA_URL)
|
||||||
|
super.onRestoreInstanceState(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"if (typeof OCA.RichDocuments.documentsMain.postGrabFocus !== 'undefined') " +
|
||||||
|
"{ OCA.RichDocuments.documentsMain.postGrabFocus(); }",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printFile(url: Uri) {
|
||||||
|
val account = accountManager.currentOwnCloudAccount
|
||||||
|
if (account == null) {
|
||||||
|
DisplayUtils.showSnackMessage(webView, getString(R.string.failed_to_print))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val targetFile = File(FileStorageUtils.getTemporalPath(account.name) + "/print.pdf")
|
||||||
|
PrintAsyncTask(targetFile, url.toString(), WeakReference(this)).execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun loadUrl(url: String) {
|
||||||
|
if (TextUtils.isEmpty(url)) {
|
||||||
|
RichDocumentsLoadUrlTask(this, user.get(), file).execute()
|
||||||
|
} else {
|
||||||
|
super.loadUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSlideShow(url: Uri) {
|
||||||
|
val intent = Intent(this, ExternalSiteWebView::class.java)
|
||||||
|
intent.putExtra(EXTRA_URL, url.toString())
|
||||||
|
intent.putExtra(EXTRA_SHOW_SIDEBAR, false)
|
||||||
|
intent.putExtra(EXTRA_SHOW_TOOLBAR, false)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class RichDocumentsMobileInterface : MobileInterface() {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun insertGraphic() {
|
||||||
|
openFileChooser()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun documentLoaded() {
|
||||||
|
runOnUiThread { hideLoading() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun downloadAs(json: String?) {
|
||||||
|
try {
|
||||||
|
json ?: return
|
||||||
|
val downloadJson = JSONObject(json)
|
||||||
|
val url = Uri.parse(downloadJson.getString(URL))
|
||||||
|
when (downloadJson.getString(TYPE)) {
|
||||||
|
PRINT -> printFile(url)
|
||||||
|
SLIDESHOW -> showSlideShow(url)
|
||||||
|
else -> downloadFile(url)
|
||||||
|
}
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
Log_OC.e(this, "Failed to parse download json message: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun fileRename(renameString: String?) {
|
||||||
|
// when shared file is renamed in another instance, we will get notified about it
|
||||||
|
// need to change filename for sharing
|
||||||
|
try {
|
||||||
|
renameString ?: return
|
||||||
|
val renameJson = JSONObject(renameString)
|
||||||
|
val newName = renameJson.getString(NEW_NAME)
|
||||||
|
file.fileName = newName
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
Log_OC.e(this, "Failed to parse rename json message: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun paste() {
|
||||||
|
// Javascript cannot do this by itself, so help out.
|
||||||
|
webView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_PASTE))
|
||||||
|
webView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_PASTE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun hyperlink(hyperlink: String?) {
|
||||||
|
try {
|
||||||
|
hyperlink ?: return
|
||||||
|
val url = JSONObject(hyperlink).getString(HYPERLINK)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = Uri.parse(url)
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
Log_OC.e(this, "Failed to parse download json message: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val URL = "URL"
|
||||||
|
private const val HYPERLINK = "Url"
|
||||||
|
private const val TYPE = "Type"
|
||||||
|
private const val PRINT = "print"
|
||||||
|
private const val SLIDESHOW = "slideshow"
|
||||||
|
private const val NEW_NAME = "NewName"
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,36 +17,36 @@
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
-->
|
-->
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center_horizontal">
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
<androidx.cardview.widget.CardView xmlns:card_view="http://schemas.android.com/apk/res-auto"
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:id="@+id/card_view"
|
xmlns:card_view="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_margin="@dimen/standard_double_margin"
|
android:layout_margin="@dimen/standard_double_margin"
|
||||||
card_view:cardCornerRadius="4dp"
|
card_view:strokeWidth="0dp"
|
||||||
|
card_view:cardCornerRadius="16dp"
|
||||||
card_view:cardElevation="@dimen/dialog_elevation">
|
card_view:cardElevation="@dimen/dialog_elevation">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/card_view_content"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingStart="@dimen/standard_padding"
|
android:padding="@dimen/standard_padding">
|
||||||
android:paddingTop="@dimen/standard_padding"
|
|
||||||
android:paddingEnd="@dimen/standard_padding"
|
|
||||||
android:paddingBottom="@dimen/standard_half_padding">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:id="@+id/header"
|
android:id="@+id/header"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
@ -56,7 +56,7 @@
|
||||||
android:textSize="@dimen/two_line_primary_text_size"
|
android:textSize="@dimen/two_line_primary_text_size"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:id="@+id/explanation"
|
android:id="@+id/explanation"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
@ -96,12 +96,14 @@
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/cancel"
|
android:id="@+id/cancel"
|
||||||
style="@style/Button.Borderless"
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="end"
|
android:layout_gravity="end"
|
||||||
android:text="@string/common_cancel" />
|
android:text="@string/common_cancel" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</androidx.cardview.widget.CardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<string name="actionbar_copy">Másolás</string>
|
<string name="actionbar_copy">Másolás</string>
|
||||||
<string name="actionbar_mkdir">Új mappa</string>
|
<string name="actionbar_mkdir">Új mappa</string>
|
||||||
<string name="actionbar_move">Áthelyezés</string>
|
<string name="actionbar_move">Áthelyezés</string>
|
||||||
|
<string name="actionbar_move_or_copy">Áthelyezés vagy Másolás</string>
|
||||||
<string name="actionbar_open_with">Megnyitás ezzel</string>
|
<string name="actionbar_open_with">Megnyitás ezzel</string>
|
||||||
<string name="actionbar_search">Keresés</string>
|
<string name="actionbar_search">Keresés</string>
|
||||||
<string name="actionbar_see_details">Részletek</string>
|
<string name="actionbar_see_details">Részletek</string>
|
||||||
|
@ -324,6 +325,7 @@
|
||||||
<string name="file_delete">Törlés</string>
|
<string name="file_delete">Törlés</string>
|
||||||
<string name="file_detail_activity_error">Hiba a fájl tevékenységeinek lekérésekor</string>
|
<string name="file_detail_activity_error">Hiba a fájl tevékenységeinek lekérésekor</string>
|
||||||
<string name="file_details_no_content">A részletek betöltése sikertelen</string>
|
<string name="file_details_no_content">A részletek betöltése sikertelen</string>
|
||||||
|
<string name="file_downloader_notification_title_prefix">Letöltés \u0020</string>
|
||||||
<string name="file_icon">Fájl</string>
|
<string name="file_icon">Fájl</string>
|
||||||
<string name="file_keep">Megtartás</string>
|
<string name="file_keep">Megtartás</string>
|
||||||
<string name="file_list_empty">Töltsön fel új tartalmat vagy szinkronizáljon az eszközeivel</string>
|
<string name="file_list_empty">Töltsön fel új tartalmat vagy szinkronizáljon az eszközeivel</string>
|
||||||
|
@ -962,7 +964,9 @@ A Nextcloud itt érhető el: https://nextcloud.com</string>
|
||||||
<string name="wait_a_moment">Egy pillanat…</string>
|
<string name="wait_a_moment">Egy pillanat…</string>
|
||||||
<string name="wait_checking_credentials">Tárolt hitelesítő adatok ellenőrzése</string>
|
<string name="wait_checking_credentials">Tárolt hitelesítő adatok ellenőrzése</string>
|
||||||
<string name="wait_for_tmp_copy_from_private_storage">Fájl másolása a privát tárolóról</string>
|
<string name="wait_for_tmp_copy_from_private_storage">Fájl másolása a privát tárolóról</string>
|
||||||
|
<string name="webview_version_check_alert_dialog_message">A bejelentkezéshez frissítse az Android System WebView alkalmazást</string>
|
||||||
<string name="webview_version_check_alert_dialog_positive_button_title">Frissítés</string>
|
<string name="webview_version_check_alert_dialog_positive_button_title">Frissítés</string>
|
||||||
|
<string name="webview_version_check_alert_dialog_title">Frissítse az Android System WebView-t</string>
|
||||||
<string name="what_s_new_image">Újdonságok kép</string>
|
<string name="what_s_new_image">Újdonságok kép</string>
|
||||||
<string name="whats_new_skip">Kihagyás</string>
|
<string name="whats_new_skip">Kihagyás</string>
|
||||||
<string name="whats_new_title">Új itt: %1$s</string>
|
<string name="whats_new_title">Új itt: %1$s</string>
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<string name="actionbar_copy">Копіювати</string>
|
<string name="actionbar_copy">Копіювати</string>
|
||||||
<string name="actionbar_mkdir">Новий каталог</string>
|
<string name="actionbar_mkdir">Новий каталог</string>
|
||||||
<string name="actionbar_move">Перемістити</string>
|
<string name="actionbar_move">Перемістити</string>
|
||||||
|
<string name="actionbar_move_or_copy">Перемістити або копіювати</string>
|
||||||
<string name="actionbar_open_with">Відкрити за допомогою</string>
|
<string name="actionbar_open_with">Відкрити за допомогою</string>
|
||||||
<string name="actionbar_search">Пошук</string>
|
<string name="actionbar_search">Пошук</string>
|
||||||
<string name="actionbar_see_details">Деталі</string>
|
<string name="actionbar_see_details">Деталі</string>
|
||||||
|
@ -322,6 +323,7 @@
|
||||||
<string name="file_delete">Вилучити</string>
|
<string name="file_delete">Вилучити</string>
|
||||||
<string name="file_detail_activity_error">Помилка з отриманням дії для файлу</string>
|
<string name="file_detail_activity_error">Помилка з отриманням дії для файлу</string>
|
||||||
<string name="file_details_no_content">Не вдалося завантажити подробиці</string>
|
<string name="file_details_no_content">Не вдалося завантажити подробиці</string>
|
||||||
|
<string name="file_downloader_notification_title_prefix">Звантаження \u0020</string>
|
||||||
<string name="file_icon">Файл</string>
|
<string name="file_icon">Файл</string>
|
||||||
<string name="file_keep">Зберегти</string>
|
<string name="file_keep">Зберегти</string>
|
||||||
<string name="file_list_empty">Додати дані або синхронізувати з вашими пристроями.</string>
|
<string name="file_list_empty">Додати дані або синхронізувати з вашими пристроями.</string>
|
||||||
|
@ -574,6 +576,7 @@
|
||||||
<string name="prefs_calendar_contacts_sync_setup_successful">Налаштування синхронізації календаря та контактів</string>
|
<string name="prefs_calendar_contacts_sync_setup_successful">Налаштування синхронізації календаря та контактів</string>
|
||||||
<string name="prefs_category_about">Про програму</string>
|
<string name="prefs_category_about">Про програму</string>
|
||||||
<string name="prefs_category_details">Деталі</string>
|
<string name="prefs_category_details">Деталі</string>
|
||||||
|
<string name="prefs_category_dev">Розробка</string>
|
||||||
<string name="prefs_category_general">Основне</string>
|
<string name="prefs_category_general">Основне</string>
|
||||||
<string name="prefs_category_more">Більше</string>
|
<string name="prefs_category_more">Більше</string>
|
||||||
<string name="prefs_daily_backup_summary">Щоденне створення резервних копій календарів та контактів</string>
|
<string name="prefs_daily_backup_summary">Щоденне створення резервних копій календарів та контактів</string>
|
||||||
|
@ -934,7 +937,9 @@
|
||||||
<string name="wait_a_moment">Зачекайте трохи…</string>
|
<string name="wait_a_moment">Зачекайте трохи…</string>
|
||||||
<string name="wait_checking_credentials">Перевірка збережених даних авторизації</string>
|
<string name="wait_checking_credentials">Перевірка збережених даних авторизації</string>
|
||||||
<string name="wait_for_tmp_copy_from_private_storage">Копіювання файлу з приватного сховища</string>
|
<string name="wait_for_tmp_copy_from_private_storage">Копіювання файлу з приватного сховища</string>
|
||||||
|
<string name="webview_version_check_alert_dialog_message">Оновіть застосунок Android System WebView для отримання доступу до входу</string>
|
||||||
<string name="webview_version_check_alert_dialog_positive_button_title">Оновлення</string>
|
<string name="webview_version_check_alert_dialog_positive_button_title">Оновлення</string>
|
||||||
|
<string name="webview_version_check_alert_dialog_title">Оновити Android System WebView</string>
|
||||||
<string name="what_s_new_image">Зображення про нові функції</string>
|
<string name="what_s_new_image">Зображення про нові функції</string>
|
||||||
<string name="whats_new_skip">Пропустити</string>
|
<string name="whats_new_skip">Пропустити</string>
|
||||||
<string name="whats_new_title">Нове у %1$s</string>
|
<string name="whats_new_title">Нове у %1$s</string>
|
||||||
|
|
Loading…
Reference in a new issue