Implement on-demand authentication

Add the UI and logic to ask for credentials if the user tries to access a protected share while browsing. Give an option of just authenticating and saving the credentials for future use or also pinning the share to the left pane.
This commit is contained in:
rthakohov 2017-08-09 09:03:38 -07:00
parent 6cd5c59f3d
commit b47e7db24a
11 changed files with 407 additions and 62 deletions

View file

@ -48,6 +48,7 @@
</intent-filter>
</provider>
<activity android:name=".auth.AuthActivity" />
</application>
</manifest>

View file

@ -45,6 +45,7 @@ public class ShareManager implements Iterable<String> {
// JSON value
private static final String URI_KEY = "uri";
private static final String MOUNT_KEY = "mount";
private static final String CREDENTIAL_TUPLE_KEY = "credentialTuple";
private static final String WORKGROUP_KEY = "workgroup";
private static final String USERNAME_KEY = "username";
@ -52,6 +53,7 @@ public class ShareManager implements Iterable<String> {
private final SharedPreferences mPref;
private final Set<String> mServerStringSet;
private final Set<String> mMountedServerSet = new HashSet<>();
private final Map<String, String> mServerStringMap = new HashMap<>();
private final CredentialCache mCredentialCache;
@ -61,13 +63,13 @@ public class ShareManager implements Iterable<String> {
mCredentialCache = credentialCache;
mPref = context.getSharedPreferences(SERVER_CACHE_PREF_KEY, Context.MODE_PRIVATE);
// Loading mounted servers
// Loading saved servers.
final Set<String> serverStringSet =
mPref.getStringSet(SERVER_STRING_SET_KEY, Collections.<String> emptySet());
final Map<String, CredentialTuple> credentialMap = new HashMap<>(serverStringSet.size());
final Map<String, ShareTuple> shareMap = new HashMap<>(serverStringSet.size());
for (String serverString : serverStringSet) {
// TODO: Add decryption
String uri = decode(serverString, credentialMap);
String uri = decode(serverString, shareMap);
if (uri != null) {
mServerStringMap.put(uri, serverString);
}
@ -75,24 +77,89 @@ public class ShareManager implements Iterable<String> {
mServerStringSet = new HashSet<>(serverStringSet);
for (Map.Entry<String, CredentialTuple> server : credentialMap.entrySet()) {
final CredentialTuple tuple = server.getValue();
for (Map.Entry<String, ShareTuple> server : shareMap.entrySet()) {
final ShareTuple tuple = server.getValue();
if (tuple.mIsMounted) {
mMountedServerSet.add(server.getKey());
}
mCredentialCache.putCredential(
server.getKey(), tuple.mWorkgroup, tuple.mUsername, tuple.mPassword);
}
}
public synchronized void mountServer(
String uri, String workgroup, String username, String password, ShareMountChecker checker)
throws IOException {
if (mServerStringMap.containsKey(uri)) {
throw new IllegalStateException("Uri " + uri + " is already mounted.");
/**
* Save the server and credentials to permanent storage.
* Throw an exception if a server with such a uri is already present.
*/
public synchronized void addServer(
String uri, String workgroup, String username, String password,
ShareMountChecker checker, boolean mount) throws IOException {
if (mMountedServerSet.contains(uri)) {
throw new IllegalStateException("Uri " + uri + " is already stored.");
}
saveServerInfo(uri, workgroup, username, password, checker, mount);
}
/**
* Update the server info. If a server with such a uri doesn't exist, create it.
*/
public synchronized void addOrUpdateServer(
String uri, String workgroup, String username, String password,
ShareMountChecker checker, boolean mount) throws IOException {
saveServerInfo(uri, workgroup, username, password, checker, mount);
}
private void saveServerInfo(
String uri, String workgroup, String username, String password,
ShareMountChecker checker, boolean mount) throws IOException {
checkServerCredentials(uri, workgroup, username, password, checker);
final boolean hasPassword = !TextUtils.isEmpty(username) && !TextUtils.isEmpty(password);
if (hasPassword) {
final ShareTuple tuple = hasPassword
? new ShareTuple(workgroup, username, password, mount)
: ShareTuple.EMPTY_TUPLE;
updateServersData(uri, tuple, mount);
}
private void updateServersData(
String uri, ShareTuple tuple, boolean shouldNotify) {
final String serverString = encode(uri, tuple);
if (serverString == null) {
throw new IllegalStateException("Failed to encode credential tuple.");
}
// TODO: Add encryption
mServerStringSet.add(serverString);
if (tuple.mIsMounted) {
mMountedServerSet.add(uri);
} else {
mMountedServerSet.remove(uri);
}
mPref.edit().putStringSet(SERVER_STRING_SET_KEY, mServerStringSet).apply();
mServerStringMap.put(uri, serverString);
if (shouldNotify) {
notifyServerChange();
}
}
private void checkServerCredentials(
String uri, String workgroup, String username, String password, ShareMountChecker checker)
throws IOException {
if (!username.isEmpty() && !password.isEmpty()) {
mCredentialCache.putCredential(uri, workgroup, username, password);
}
runMountChecker(uri, checker);
}
private void runMountChecker(String uri, ShareMountChecker checker) throws IOException {
try {
checker.checkShareMounting();
} catch (Exception e) {
@ -100,20 +167,6 @@ public class ShareManager implements Iterable<String> {
mCredentialCache.removeCredential(uri);
throw e;
}
final CredentialTuple tuple = hasPassword
? new CredentialTuple(workgroup, username, password)
: CredentialTuple.EMPTY_TUPLE;
final String serverString = encode(uri, tuple);
if (serverString == null) {
throw new IllegalStateException("Failed to encode credential tuple.");
}
// TODO: Add encryption
mServerStringSet.add(serverString);
mPref.edit().putStringSet(SERVER_STRING_SET_KEY, mServerStringSet).apply();
mServerStringMap.put(uri, serverString);
notifyServerChange();
}
public synchronized boolean unmountServer(String uri) {
@ -149,6 +202,10 @@ public class ShareManager implements Iterable<String> {
return mServerStringMap.containsKey(uri);
}
public synchronized boolean isShareMounted(String uri) {
return mMountedServerSet.contains(uri);
}
private void notifyServerChange() {
for (int i = mListeners.size() - 1; i >= 0; --i) {
mListeners.get(i).onMountedServerChange();
@ -163,7 +220,7 @@ public class ShareManager implements Iterable<String> {
mListeners.remove(listener);
}
private static String encode(String uri, CredentialTuple tuple) {
private static String encode(String uri, ShareTuple tuple) {
final StringWriter stringWriter = new StringWriter();
try (final JsonWriter jsonWriter = new JsonWriter(stringWriter)) {
jsonWriter.beginObject();
@ -180,25 +237,26 @@ public class ShareManager implements Iterable<String> {
return stringWriter.toString();
}
private static void encodeTuple(JsonWriter writer, CredentialTuple tuple) throws IOException {
if (tuple == CredentialTuple.EMPTY_TUPLE) {
private static void encodeTuple(JsonWriter writer, ShareTuple tuple) throws IOException {
if (tuple == ShareTuple.EMPTY_TUPLE) {
writer.nullValue();
} else {
writer.beginObject();
writer.name(WORKGROUP_KEY).value(tuple.mWorkgroup);
writer.name(USERNAME_KEY).value(tuple.mUsername);
writer.name(PASSWORD_KEY).value(tuple.mPassword);
writer.name(MOUNT_KEY).value(tuple.mIsMounted);
writer.endObject();
}
}
private static String decode(String content, Map<String, CredentialTuple> credentialMap) {
private static String decode(String content, Map<String, ShareTuple> shareMap) {
final StringReader stringReader = new StringReader(content);
try (final JsonReader jsonReader = new JsonReader(stringReader)) {
jsonReader.beginObject();
String uri = null;
CredentialTuple tuple = null;
ShareTuple tuple = null;
while (jsonReader.hasNext()) {
final String name = jsonReader.nextName();
switch (name) {
@ -217,7 +275,7 @@ public class ShareManager implements Iterable<String> {
if (uri == null || tuple == null) {
throw new IllegalStateException("Either uri or tuple is null.");
}
credentialMap.put(uri, tuple);
shareMap.put(uri, tuple);
return uri;
} catch (IOException e) {
@ -226,48 +284,51 @@ public class ShareManager implements Iterable<String> {
}
}
private static CredentialTuple decodeTuple(JsonReader reader) throws IOException {
private static ShareTuple decodeTuple(JsonReader reader) throws IOException {
if (reader.peek() == JsonToken.NULL) {
reader.nextNull();
return CredentialTuple.EMPTY_TUPLE;
return ShareTuple.EMPTY_TUPLE;
}
String workgroup = null;
String username = null;
String password = null;
boolean mounted = true;
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
String value = reader.nextString();
switch (name) {
case WORKGROUP_KEY:
workgroup = value;
workgroup = reader.nextString();
break;
case USERNAME_KEY:
username = value;
username = reader.nextString();
break;
case PASSWORD_KEY:
password = value;
password = reader.nextString();
break;
case MOUNT_KEY:
mounted = reader.nextBoolean();
default:
Log.w(TAG, "Ignoring unknown key " + name);
}
}
reader.endObject();
return new CredentialTuple(workgroup, username, password);
return new ShareTuple(workgroup, username, password, mounted);
}
private static class CredentialTuple {
private static final CredentialTuple EMPTY_TUPLE = new CredentialTuple("", "", "");
private static class ShareTuple {
private static final ShareTuple EMPTY_TUPLE = new ShareTuple("", "", "", true);
private final String mWorkgroup;
private final String mUsername;
private final String mPassword;
private boolean mIsMounted;
private CredentialTuple(String workgroup, String username, String password) {
private ShareTuple(String workgroup, String username, String password, boolean isMounted) {
if (workgroup == null) {
throw new IllegalArgumentException("workgroup is null.");
}
@ -280,6 +341,7 @@ public class ShareManager implements Iterable<String> {
mWorkgroup = workgroup;
mUsername = username;
mPassword = password;
mIsMounted = isMounted;
}
}

View file

@ -0,0 +1,165 @@
/*
* Copyright 2017 Google Inc.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.google.android.sambadocumentsprovider.auth;
import android.app.PendingIntent;
import android.app.ProgressDialog;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.Toast;
import com.google.android.sambadocumentsprovider.R;
import com.google.android.sambadocumentsprovider.SambaProviderApplication;
import com.google.android.sambadocumentsprovider.ShareManager;
import com.google.android.sambadocumentsprovider.base.AuthFailedException;
import com.google.android.sambadocumentsprovider.base.OnTaskFinishedCallback;
import com.google.android.sambadocumentsprovider.nativefacade.SmbClient;
public class AuthActivity extends AppCompatActivity {
private static final String TAG = "AuthActivity";
private static final String SHARE_URI_KEY = "shareUri";
private EditText mSharePathEditText;
private EditText mDomainEditText;
private EditText mUsernameEditText;
private EditText mPasswordEditText;
private CheckBox mPinShareCheckbox;
private ProgressDialog progressDialog;
private ShareManager mShareManager;
private SmbClient mClient;
private final View.OnClickListener mLoginListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
tryAuth();
}
};
private final OnTaskFinishedCallback<Void> callback = new OnTaskFinishedCallback<Void>() {
@Override
public void onTaskFinished(@Status int status, @Nullable Void item, @Nullable Exception e) {
progressDialog.dismiss();
if (status == SUCCEEDED) {
setResult(RESULT_OK);
finish();
} else {
Log.i(TAG, "Authentication failed: ", e);
showMessage(R.string.credential_error);
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final Context context = getApplicationContext();
mShareManager = SambaProviderApplication.getServerManager(context);
mClient = SambaProviderApplication.getSambaClient(context);
Intent authIntent = getIntent();
String shareUri = authIntent.getStringExtra(SHARE_URI_KEY);
prepareUI(shareUri);
}
private void prepareUI(String shareUri) {
mSharePathEditText = (EditText) findViewById(R.id.share_path);
mUsernameEditText = (EditText) findViewById(R.id.username);
mDomainEditText = (EditText) findViewById(R.id.domain);
mPasswordEditText = (EditText) findViewById(R.id.password);
CheckBox passwordCheckbox = (CheckBox) findViewById(R.id.needs_password);
mPinShareCheckbox = (CheckBox) findViewById(R.id.pin_share);
mSharePathEditText.setText(shareUri);
mSharePathEditText.setEnabled(false);
passwordCheckbox.setVisibility(View.GONE);
mPinShareCheckbox.setVisibility(View.VISIBLE);
Button mLoginButton = (Button) findViewById(R.id.mount);
mLoginButton.setText(getResources().getString(R.string.login));
mLoginButton.setOnClickListener(mLoginListener);
final Button cancel = (Button) findViewById(R.id.cancel);
cancel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
finish();
}
});
}
private void tryAuth() {
progressDialog = ProgressDialog.show(
this, null, getResources().getString(R.string.authenticating), true);
final String username = mUsernameEditText.getText().toString();
final String password = mPasswordEditText.getText().toString();
if (username.isEmpty() || password.isEmpty()) {
showMessage(R.string.empty_credentials);
return;
}
new AuthorizationTask(
mSharePathEditText.getText().toString(),
username,
password,
mDomainEditText.getText().toString(),
mPinShareCheckbox.isChecked(),
mShareManager,
mClient,
callback).execute();
}
private void showMessage(@StringRes int id) {
Snackbar.make(mPinShareCheckbox, id, Snackbar.LENGTH_SHORT).show();
}
public static PendingIntent createAuthIntent(Context context, String shareUri) {
Intent authIntent = new Intent();
authIntent.setComponent(new ComponentName(
context.getPackageName(),
AuthActivity.class.getName()));
authIntent.putExtra(SHARE_URI_KEY, shareUri);
return PendingIntent.getActivity(
context, 0, authIntent, PendingIntent.FLAG_UPDATE_CURRENT);
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright 2017 Google Inc.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.google.android.sambadocumentsprovider.auth;
import android.net.Uri;
import com.google.android.sambadocumentsprovider.ShareManager;
import com.google.android.sambadocumentsprovider.base.BiResultTask;
import com.google.android.sambadocumentsprovider.base.OnTaskFinishedCallback;
import com.google.android.sambadocumentsprovider.document.DocumentMetadata;
import com.google.android.sambadocumentsprovider.nativefacade.SmbClient;
import java.io.IOException;
class AuthorizationTask extends BiResultTask<Void, Void, Void> {
private static final String TAG = "AuthorizationTask";
private final String mUri;
private final String mUser;
private final String mPassword;
private final String mDomain;
private final boolean mShouldPin;
private final ShareManager mShareManager;
private final SmbClient mClient;
private final OnTaskFinishedCallback<Void> mCallback;
AuthorizationTask(String uri, String user, String password, String domain, boolean shouldPin,
ShareManager shareManager, SmbClient client,
OnTaskFinishedCallback<Void> callback) {
mUri = uri;
mUser = user;
mPassword = password;
mDomain = domain;
mShouldPin = shouldPin;
mShareManager = shareManager;
mClient = client;
mCallback = callback;
}
@Override
public Void run(Void... voids) throws Exception {
final DocumentMetadata shareMetadata = DocumentMetadata.createShare(Uri.parse(mUri));
final ShareManager.ShareMountChecker mountChecker = new ShareManager.ShareMountChecker() {
@Override
public void checkShareMounting() throws IOException {
shareMetadata.loadChildren(mClient);
}
};
mShareManager.addOrUpdateServer(mUri, mDomain, mUser, mPassword, mountChecker, mShouldPin);
return null;
}
@Override
public void onSucceeded(Void aVoid) {
mCallback.onTaskFinished(OnTaskFinishedCallback.SUCCEEDED, null, null);
}
@Override
public void onFailed(Exception e) {
mCallback.onTaskFinished(OnTaskFinishedCallback.FAILED, null, e);
}
}

View file

@ -137,7 +137,7 @@ class BroadcastUtils {
final int type = buffer.get();
if (type == FILE_SERVER_NODE_TYPE) {
servers.add(serverName);
servers.add(serverName.trim());
}
skipBytes(buffer, 2);

View file

@ -310,6 +310,10 @@ public class DocumentMetadata {
return uri.getPathSegments().isEmpty() && !uri.getAuthority().isEmpty();
}
public static boolean isShareUri(Uri uri) {
return uri.getPathSegments().size() == 1;
}
public static DocumentMetadata fromUri(Uri uri, SmbClient client) throws IOException {
final List<String> pathSegments = uri.getPathSegments();
if (pathSegments.isEmpty()) {

View file

@ -70,6 +70,8 @@ public class MountServerActivity extends AppCompatActivity {
private static final String DOMAIN_KEY = "domain";
private static final String USERNAME_KEY = "username";
private static final String PASSWORD_KEY = "password";
private static final String AUTH_LAUNCH_KEY = "authLaunch";
private final OnClickListener mPasswordStateChangeListener = new OnClickListener() {
@Override
@ -136,8 +138,8 @@ public class MountServerActivity extends AppCompatActivity {
mPasswordEditText = (EditText) findViewById(R.id.password);
mPasswordEditText.setOnKeyListener(mMountKeyListener);
final Button mount = (Button) findViewById(R.id.mount);
mount.setOnClickListener(mMountListener);
final Button mMountShareButton = (Button) findViewById(R.id.mount);
mMountShareButton.setOnClickListener(mMountListener);
final Button cancel = (Button) findViewById(R.id.cancel);
cancel.setOnClickListener(new OnClickListener() {
@ -147,6 +149,8 @@ public class MountServerActivity extends AppCompatActivity {
}
});
setNeedsPasswordState(false);
// Set MovementMethod to make it respond to clicks on hyperlinks
final TextView gplv3Link = (TextView) findViewById(R.id.gplv3_link);
gplv3Link.setMovementMethod(LinkMovementMethod.getInstance());
@ -162,6 +166,7 @@ public class MountServerActivity extends AppCompatActivity {
}
mSharePathEditText.setText(savedInstanceState.getString(SHARE_PATH_KEY, ""));
final boolean needsPassword = savedInstanceState.getBoolean(NEEDS_PASSWORD_KEY);
setNeedsPasswordState(needsPassword);
if (needsPassword) {
@ -236,7 +241,7 @@ public class MountServerActivity extends AppCompatActivity {
final DocumentMetadata metadata = DocumentMetadata.createShare(host, share);
if (mShareManager.containsShare(metadata.getUri().toString())) {
if (mShareManager.isShareMounted(metadata.getUri().toString())) {
showMessage(R.string.share_already_mounted);
return;
}

View file

@ -70,8 +70,8 @@ class MountServerTask extends BiResultTask<Void, Void, Void> {
@Override
public Void run(Void... args) throws IOException {
mShareManager.mountServer(
mMetadata.getUri().toString(), mDomain, mUsername, mPassword, mChecker);
mShareManager.addServer(
mMetadata.getUri().toString(), mDomain, mUsername, mPassword, mChecker, true);
return null;
}

View file

@ -22,6 +22,7 @@ import static com.google.android.sambadocumentsprovider.base.DocumentIdHelper.to
import static com.google.android.sambadocumentsprovider.base.DocumentIdHelper.toUri;
import static com.google.android.sambadocumentsprovider.base.DocumentIdHelper.toUriString;
import android.app.AuthenticationRequiredException;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
@ -46,6 +47,7 @@ import com.google.android.sambadocumentsprovider.SambaProviderApplication;
import com.google.android.sambadocumentsprovider.ShareManager;
import com.google.android.sambadocumentsprovider.ShareManager.MountedShareChangeListener;
import com.google.android.sambadocumentsprovider.TaskManager;
import com.google.android.sambadocumentsprovider.auth.AuthActivity;
import com.google.android.sambadocumentsprovider.base.AuthFailedException;
import com.google.android.sambadocumentsprovider.base.DirectoryEntry;
import com.google.android.sambadocumentsprovider.base.DocumentCursor;
@ -57,11 +59,11 @@ import com.google.android.sambadocumentsprovider.document.LoadChildrenTask;
import com.google.android.sambadocumentsprovider.base.OnTaskFinishedCallback;
import com.google.android.sambadocumentsprovider.document.LoadDocumentTask;
import com.google.android.sambadocumentsprovider.document.LoadStatTask;
import com.google.android.sambadocumentsprovider.mount.MountServerActivity;
import com.google.android.sambadocumentsprovider.nativefacade.SmbFacade;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
@ -186,7 +188,7 @@ public class SambaDocumentsProvider extends DocumentsProvider {
if(BuildConfig.DEBUG) Log.d(TAG, "Querying roots.");
projection = (projection == null) ? DEFAULT_ROOT_PROJECTION : projection;
MatrixCursor cursor = new MatrixCursor(projection, mShareManager.size());
MatrixCursor cursor = new MatrixCursor(projection);
cursor.addRow(new Object[] {
NetworkBrowser.SMB_BROWSING_URI.toString(),
@ -197,6 +199,10 @@ public class SambaDocumentsProvider extends DocumentsProvider {
});
for (String uri : mShareManager) {
if (!mShareManager.isShareMounted(uri)) {
continue;
}
final String name;
final Uri parsedUri = Uri.parse(uri);
try(CacheResult result = mCache.get(parsedUri)) {
@ -273,7 +279,7 @@ public class SambaDocumentsProvider extends DocumentsProvider {
@Override
public Cursor queryChildDocuments(String documentId, String[] projection, String sortOrder)
throws FileNotFoundException {
throws FileNotFoundException, AuthenticationRequiredException {
if (BuildConfig.DEBUG) Log.d(TAG, "Querying children documents under " + documentId);
projection = (projection == null) ? DEFAULT_DOCUMENT_PROJECTION : projection;
@ -360,7 +366,12 @@ public class SambaDocumentsProvider extends DocumentsProvider {
return cursor;
}
} catch (AuthFailedException e) {
return buildErrorCursor(projection, R.string.view_folder_denied);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && DocumentMetadata.isShareUri(uri)) {
throw new AuthenticationRequiredException(
e, AuthActivity.createAuthIntent(getContext(), uri.toString()));
} else {
return buildErrorCursor(projection, R.string.view_folder_denied);
}
} catch (FileNotFoundException|RuntimeException e) {
throw e;
} catch (Exception e) {

View file

@ -54,43 +54,53 @@
android:layout_width="match_parent"
android:layout_height="@dimen/clickable_height"
android:layout_below="@id/share_path"
android:checked="false"
android:text="@string/needs_password"/>
<LinearLayout
android:id="@+id/password_hide_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:orientation="vertical">
<EditText
android:id="@+id/domain"
android:layout_width="match_parent"
android:layout_height="@dimen/clickable_height"
android:minLines="1"
android:maxLines="1"
android:hint="@string/domain"
android:inputType="text"
android:hint="@string/domain"/>
android:maxLines="1"
android:minLines="1"/>
<EditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="@dimen/clickable_height"
android:minLines="1"
android:maxLines="1"
android:hint="@string/username"
android:inputType="text"
android:hint="@string/username"/>
android:maxLines="1"
android:minLines="1"/>
<EditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="@dimen/clickable_height"
android:minLines="1"
android:maxLines="1"
android:inputType="textPassword"
android:fontFamily="sans-serif"
android:hint="@string/password"/>
android:hint="@string/password"
android:inputType="textPassword"
android:maxLines="1"
android:minLines="1"/>
</LinearLayout>
<CheckBox
android:id="@+id/pin_share"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/pin_this_share"
android:visibility="gone"/>
</LinearLayout>
</ScrollView>

View file

@ -49,4 +49,9 @@
<string name="no_web_browser">It needs a web browser to send feedback.</string>
<string name="browsing_root_name">Samba Shares</string>
<string name="pin_this_share">Pin this share</string>
<string name="login">Login</string>
<string name="authenticating">Authenticating...</string>
<string name="empty_credentials">Username and password can not be empty!</string>
</resources>