Added support for simple password protected web socket sessions in the

server plugin and android client.
This commit is contained in:
casey langen 2017-02-16 23:37:17 -08:00
parent 7905dc5e34
commit f4510df6e3
17 changed files with 396 additions and 201 deletions

View File

@ -18,6 +18,7 @@
#include <json.hpp>
#include <map>
#include <unordered_map>
#include <set>
#ifdef WIN32
@ -27,6 +28,7 @@
#endif
#define DEFAULT_PORT 9002
#define DEFAULT_PASSWORD ""
using namespace musik::core::sdk;
@ -37,9 +39,10 @@ using websocketpp::lib::placeholders::_2;
using websocketpp::lib::bind;
using server = websocketpp::server<websocketpp::config::asio>;
using connection_hdl = websocketpp::connection_hdl;
using connection_list = std::set<connection_hdl, std::owner_less<connection_hdl>>;
using message_ptr = server::message_ptr;
using ConnectionList = std::map<connection_hdl, bool, std::owner_less<connection_hdl>>;
typedef boost::shared_mutex Mutex;
typedef boost::unique_lock<Mutex> WriteLock;
typedef boost::shared_lock<Mutex> ReadLock;
@ -105,10 +108,14 @@ namespace key {
static const std::string index = "index";
static const std::string delta = "delta";
static const std::string relative = "relative";
static const std::string password = "password";
static const std::string port = "port";
static const std::string authenticated = "authenticated";
}
namespace value {
static const std::string invalid = "invalid";
static const std::string unauthenticated = "unauthenticated";
}
namespace type {
@ -118,6 +125,7 @@ namespace type {
}
namespace request {
static const std::string authenticate = "authenticate";
static const std::string ping = "ping";
static const std::string pause_or_resume = "pause_or_resume";
static const std::string stop = "stop";
@ -231,7 +239,39 @@ class PlaybackRemote : public IPlaybackRemote {
}
private:
void HandleAuthentication(connection_hdl connection, json& request) {
std::string name = request[message::name];
if (name == request::authenticate) {
std::string sent = request[message::options][key::password];
std::string actual = this->GetPreferenceString(
::preferences, key::password, DEFAULT_PASSWORD);
if (sent == actual) {
this->connections[connection] = true; /* mark as authed */
this->RespondWithOptions(
connection,
request,
json({ { key::authenticated, true } }));
return;
}
}
this->wss.close(
connection,
websocketpp::close::status::policy_violation,
value::unauthenticated);
}
void HandleRequest(connection_hdl connection, json& request) {
if (this->connections[connection] == false) {
this->HandleAuthentication(connection, request);
return;
}
std::string name = request[message::name];
std::string id = request[message::id];
@ -359,8 +399,8 @@ class PlaybackRemote : public IPlaybackRemote {
std::string str = msg.dump();
ReadLock rl(::stateMutex);
for (connection_hdl connection : this->connections) {
wss.send(connection, str.c_str(), websocketpp::frame::opcode::text);
for (const auto &keyValue : this->connections) {
wss.send(keyValue.first, str.c_str(), websocketpp::frame::opcode::text);
}
}
@ -787,6 +827,15 @@ class PlaybackRemote : public IPlaybackRemote {
}
}
std::string GetPreferenceString(
IPreferences* prefs,
const std::string& key,
const std::string& defaultValue)
{
prefs->GetString(key.c_str(), threadLocalBuffer, sizeof(threadLocalBuffer), defaultValue.c_str());
return std::string(threadLocalBuffer);
}
template <typename MetadataT>
std::string GetMetadataString(MetadataT* metadata, const std::string& key) {
metadata->GetValue(key.c_str(), threadLocalBuffer, sizeof(threadLocalBuffer));
@ -835,7 +884,7 @@ class PlaybackRemote : public IPlaybackRemote {
void OnOpen(connection_hdl connection) {
WriteLock wl(::stateMutex);
connections.insert(connection);
connections[connection] = false;
}
void OnClose(connection_hdl connection) {
@ -859,7 +908,7 @@ class PlaybackRemote : public IPlaybackRemote {
}
}
connection_list connections;
ConnectionList connections;
std::shared_ptr<std::thread> thread;
server wss;
@ -879,6 +928,12 @@ extern "C" DLL_EXPORT IPlaybackRemote* GetPlaybackRemote() {
extern "C" DLL_EXPORT void SetPreferences(musik::core::sdk::IPreferences* prefs) {
WriteLock wl(::stateMutex);
::preferences = prefs;
if (prefs) {
prefs->GetInt(key::port.c_str(), DEFAULT_PORT);
prefs->GetString(key::password.c_str(), nullptr, 0, DEFAULT_PASSWORD);
}
remote.CheckRunningStatus();
}

View File

@ -47,6 +47,8 @@ namespace musik { namespace core { namespace sdk {
virtual void SetInt(const char* key, int value) = 0;
virtual void SetDouble(const char* key, double value) = 0;
virtual void SetString(const char* key, const char* value) = 0;
virtual void Save() = 0;
};
} } }

View File

@ -72,6 +72,8 @@ namespace musik { namespace core {
virtual void SetDouble(const char* key, double value);
virtual void SetString(const char* key, const char* value);
virtual void Save();
/* easier interface for internal use */
virtual bool GetBool(const std::string& key, bool defaultValue = false);
virtual int GetInt(const std::string& key, int defaultValue = 0);
@ -84,7 +86,6 @@ namespace musik { namespace core {
virtual void SetString(const std::string& key, const char* value);
void GetKeys(std::vector<std::string>& target);
void Save();
private:
Preferences(const std::string& component, Mode mode);

View File

@ -108,7 +108,10 @@ public class AlbumBrowseActivity extends WebSocketActivityBase implements Filter
@Override
public void onMessageReceived(SocketMessage message) {
}
@Override
public void onInvalidPassword() {
}
};

View File

@ -108,7 +108,10 @@ public class CategoryBrowseActivity extends WebSocketActivityBase implements Fil
@Override
public void onMessageReceived(SocketMessage message) {
}
@Override
public void onInvalidPassword() {
}
};

View File

@ -0,0 +1,26 @@
package io.casey.musikcube.remote;
import android.app.Dialog;
import android.os.Bundle;
import android.support.v4.app.DialogFragment;
import android.support.v7.app.AlertDialog;
public class InvalidPasswordDialogFragment extends DialogFragment {
public static final String TAG = "InvalidPasswordDialogFragment";
public static InvalidPasswordDialogFragment newInstance() {
return new InvalidPasswordDialogFragment();
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new AlertDialog.Builder(getActivity())
.setTitle(R.string.invalid_password_dialog_title)
.setMessage(R.string.invalid_password_dialog_message)
.setPositiveButton(R.string.button_settings, (dialog, which) -> {
startActivity(SettingsActivity.getStartIntent(getActivity()));
})
.setNegativeButton(R.string.button_close, null)
.create();
}
}

View File

@ -46,6 +46,7 @@ public class MainActivity extends WebSocketActivityBase {
private CheckBox shuffleCb, muteCb, repeatCb;
private ImageView albumArtImageView;
private View mainTrackMetadataWithAlbumArt, mainTrackMetadataNoAlbumArt;
private View disconnectedOverlay;
private Handler handler = new Handler();
private ViewPropertyAnimator metadataAnim1, metadataAnim2;
@ -152,6 +153,8 @@ public class MainActivity extends WebSocketActivityBase {
this.albumArtImageView = (ImageView) findViewById(R.id.album_art);
this.connected = findViewById(R.id.connected);
this.disconnectedOverlay = findViewById(R.id.disconnected_overlay);
/* these will get faded in as appropriate */
this.mainTrackMetadataNoAlbumArt.setAlpha(0.0f);
this.mainTrackMetadataWithAlbumArt.setAlpha(0.0f);
@ -208,6 +211,12 @@ public class MainActivity extends WebSocketActivityBase {
.build());
});
notPlayingOrDisconnected.setOnClickListener((view) -> {
if (wss.getState() != WebSocketService.State.Connected) {
wss.reconnect();
}
});
findViewById(R.id.button_artists).setOnClickListener((View view) -> {
startActivity(CategoryBrowseActivity.getStartIntent(this, Messages.Category.ALBUM_ARTIST));
});
@ -296,6 +305,7 @@ public class MainActivity extends WebSocketActivityBase {
final boolean stateIsValidForArtwork = !stopped && connected;
this.connected.setVisibility((connected && stopped) ? View.VISIBLE : View.GONE);
this.disconnectedOverlay.setVisibility(connected ? View.GONE : View.VISIBLE);
/* setup our state as if we have no album art -- because we don't know if we have any
yet! the album art load process (if enabled) will ensure the correct metadata block
@ -539,5 +549,14 @@ public class MainActivity extends WebSocketActivityBase {
}
}
}
@Override
public void onInvalidPassword() {
final String tag = InvalidPasswordDialogFragment.TAG;
if (getSupportFragmentManager().findFragmentByTag(tag) == null) {
InvalidPasswordDialogFragment
.newInstance().show(getSupportFragmentManager(), tag);
}
}
};
}

View File

@ -2,6 +2,7 @@ package io.casey.musikcube.remote;
public class Messages {
public enum Request {
Authenticate("authenticate"),
Ping("ping"),
GetPlaybackOverview("get_playback_overview"),
PauseOrResume("pause_or_resume"),

View File

@ -122,6 +122,10 @@ public class PlayQueueActivity extends WebSocketActivityBase {
updatePlaybackModel(broadcast);
}
}
@Override
public void onInvalidPassword() {
}
};
private class ViewHolder extends RecyclerView.ViewHolder {

View File

@ -13,7 +13,7 @@ import android.widget.EditText;
import java.util.Locale;
public class SettingsActivity extends AppCompatActivity {
private EditText addressText, portText;
private EditText addressText, portText, passwordText;
private CheckBox albumArtCheckbox;
private SharedPreferences prefs;
@ -33,11 +33,13 @@ public class SettingsActivity extends AppCompatActivity {
private void rebindUi() {
Views.setTextAndMoveCursorToEnd(this.addressText, prefs.getString("address", "192.168.1.100"));
Views.setTextAndMoveCursorToEnd(this.portText, String.format(Locale.ENGLISH, "%d", prefs.getInt("port", 9002)));
this.passwordText.setText(prefs.getString("password", ""));
}
private void bindEventListeners() {
this.addressText = (EditText) this.findViewById(R.id.address);
this.portText = (EditText) this.findViewById(R.id.port);
this.passwordText = (EditText) this.findViewById(R.id.password);
this.albumArtCheckbox = (CheckBox) findViewById(R.id.album_art_checkbox);
this.albumArtCheckbox.setChecked(this.prefs.getBoolean("album_art_enabled", true));
@ -45,15 +47,16 @@ public class SettingsActivity extends AppCompatActivity {
this.findViewById(R.id.button_connect).setOnClickListener((View v) -> {
final String addr = addressText.getText().toString();
final String port = portText.getText().toString();
final String password = passwordText.getText().toString();
prefs.edit()
.putString("address", addr)
.putInt("port", (port.length() > 0) ? Integer.valueOf(port) : 0)
.putString("password", password)
.putBoolean("album_art_enabled", albumArtCheckbox.isChecked())
.apply();
WebSocketService.getInstance(this).disconnect();
WebSocketService.getInstance(this).reconnect();
finish();
});

View File

@ -98,6 +98,10 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
@Override
public void onMessageReceived(SocketMessage message) {
}
@Override
public void onInvalidPassword() {
}
};
private View.OnClickListener onItemClickListener = (View view) -> {

View File

@ -182,5 +182,9 @@ public class TrackListScrollCache<TrackType> {
}
}
}
@Override
public void onInvalidPassword() {
}
};
}

View File

@ -144,5 +144,9 @@ public class TransportFragment extends Fragment {
}
}
}
@Override
public void onInvalidPassword() {
}
};
}

View File

@ -33,6 +33,8 @@ public class WebSocketService {
private static final int PING_INTERVAL_MILLIS = 3500;
private static final int AUTO_CONNECT_FAILSAFE_DELAY_MILLIS = 2000;
private static final int AUTO_DISCONNECT_DELAY_MILLIS = 5000;
private static final int FLAG_AUTHENTICATION_FAILED = 0xbeef;
private static final int WEBSOCKET_FLAG_POLICY_VIOLATION = 1008;
private static final int MESSAGE_BASE = 0xcafedead;
private static final int MESSAGE_CONNECT_THREAD_FINISHED = MESSAGE_BASE + 0;
@ -45,6 +47,7 @@ public class WebSocketService {
public interface Client {
void onStateChanged(State newState, State oldState);
void onMessageReceived(SocketMessage message);
void onInvalidPassword();
}
public interface MessageResultCallback {
@ -66,7 +69,14 @@ public class WebSocketService {
public boolean handleMessage(Message message) {
if (message.what == MESSAGE_CONNECT_THREAD_FINISHED) {
if (message.obj == null) {
disconnect(true); /* auto-reconnect */
boolean invalidPassword = (message.arg1 == FLAG_AUTHENTICATION_FAILED);
disconnect(!invalidPassword); /* auto reconnect as long as password was not invalid */
if (invalidPassword) {
for (Client client : clients) {
client.onInvalidPassword();
}
}
}
else {
setSocket((WebSocket) message.obj);
@ -162,6 +172,7 @@ public class WebSocketService {
if (this.clients.size() == 1) {
registerReceiverAndScheduleFailsafe();
reconnect();
handler.removeCallbacks(autoDisconnectRunnable);
}
@ -327,12 +338,13 @@ public class WebSocketService {
}
private void connectIfNotConnected() {
if (state != State.Connected || !socket.isOpen()) {
if (state == State.Disconnected) {
disconnect(autoReconnect);
handler.removeMessages(MESSAGE_AUTO_RECONNECT);
setState(State.Connecting);
if (this.clients.size() > 0) {
handler.removeCallbacks(autoDisconnectRunnable);
setState(State.Connecting);
thread = new ConnectThread();
thread.start();
}
@ -346,7 +358,6 @@ public class WebSocketService {
}
this.socket = socket;
this.socket.addListener(webSocketAdapter);
}
}
@ -389,7 +400,7 @@ public class WebSocketService {
}
private Runnable autoReconnectFailsafeRunnable = () -> {
if (getState() != WebSocketService.State.Connected) {
if (autoReconnect && getState() == State.Disconnected) {
reconnect();
}
};
@ -401,7 +412,13 @@ public class WebSocketService {
public void onTextFrame(WebSocket websocket, WebSocketFrame frame) throws Exception {
final SocketMessage message = SocketMessage.create(frame.getPayloadText());
if (message != null) {
handler.sendMessage(Message.obtain(handler, MESSAGE_MESSAGE_RECEIVED, message));
if (message.getName().equals(Messages.Request.Authenticate.toString())) {
handler.sendMessage(Message.obtain(
handler, MESSAGE_CONNECT_THREAD_FINISHED, websocket));
}
else {
handler.sendMessage(Message.obtain(handler, MESSAGE_MESSAGE_RECEIVED, message));
}
}
}
@ -410,7 +427,12 @@ public class WebSocketService {
WebSocketFrame serverCloseFrame,
WebSocketFrame clientCloseFrame,
boolean closedByServer) throws Exception {
handler.sendMessage(Message.obtain(handler, MESSAGE_CONNECT_THREAD_FINISHED, null));
int flags = 0;
if (serverCloseFrame.getCloseCode() == WEBSOCKET_FLAG_POLICY_VIOLATION) {
flags = FLAG_AUTHENTICATION_FAILED;
}
handler.sendMessage(Message.obtain(handler, MESSAGE_CONNECT_THREAD_FINISHED, flags, 0, null));
}
};
@ -429,17 +451,27 @@ public class WebSocketService {
prefs.getInt("port", 9002));
socket = factory.createSocket(host, CONNECTION_TIMEOUT_MILLIS);
socket.addListener(webSocketAdapter);
socket.connect();
socket.setPingInterval(PING_INTERVAL_MILLIS);
/* authenticate */
final String auth = SocketMessage.Builder
.request(Messages.Request.Authenticate)
.addOption("password", prefs.getString("password", ""))
.build()
.toString();
socket.sendText(auth);
}
catch (Exception ex) {
socket = null;
}
synchronized (WebSocketService.this) {
if (!isInterrupted()) {
if (thread == this && socket == null) {
handler.sendMessage(Message.obtain(
handler, MESSAGE_CONNECT_THREAD_FINISHED, socket));
handler, MESSAGE_CONNECT_THREAD_FINISHED, null));
}
if (thread == this) {
@ -458,9 +490,8 @@ public class WebSocketService {
final NetworkInfo info = cm.getActiveNetworkInfo();
if (info != null && info.isConnected()) {
if (getState() == WebSocketService.State.Disconnected) {
disconnect();
reconnect();
if (autoReconnect) {
connectIfNotConnected();
}
}
}
@ -469,5 +500,6 @@ public class WebSocketService {
private static Client INTERNAL_CLIENT = new Client() {
public void onStateChanged(State newState, State oldState) { }
public void onMessageReceived(SocketMessage message) { }
public void onInvalidPassword() { }
};
}

View File

@ -146,209 +146,222 @@
</FrameLayout>
<LinearLayout
<FrameLayout
android:id="@+id/play_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_alignParentBottom="true"
android:orientation="vertical">
<android.support.v4.widget.Space
android:layout_width="0dp"
android:layout_height="2dp" />
android:layout_alignParentBottom="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:orientation="horizontal">
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_artists"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_artists"/>
android:orientation="vertical">
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_albums"
android:layout_width="0dp"
android:layout_height="2dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_albums"/>
android:layout_gravity="center_horizontal"
android:orientation="horizontal">
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_artists"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_artists"/>
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_tracks"
android:layout_width="0dp"
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_albums"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_albums"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_tracks"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_tracks"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_play_queue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_play_queue"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_tracks"/>
android:layout_gravity="center_horizontal"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_prev"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_prev"/>
<TextView
style="@style/BrowseButton"
android:layout_weight="1.0"
android:id="@+id/button_play_queue"
android:layout_width="0dp"
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_play_pause"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_play"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_next"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_next"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_play_queue"/>
android:layout_gravity="center_horizontal"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_vol_down"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_vol_down"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_seek_back"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_seek_back"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_seek_forward"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_seek_forward"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_vol_up"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_vol_up"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<CheckBox
style="@style/PlaybackCheckbox"
android:id="@+id/check_shuffle"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_shuffle"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<CheckBox
style="@style/PlaybackCheckbox"
android:id="@+id/check_mute"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_mute"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<CheckBox
style="@style/PlaybackCheckbox"
android:id="@+id/check_repeat"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_repeat_off"/>
</LinearLayout>
</LinearLayout>
<LinearLayout
<FrameLayout
android:id="@+id/disconnected_overlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="2dp"
android:orientation="horizontal">
android:layout_height="match_parent"
android:background="@color/theme_button_background_transparent"
android:visibility="gone"/>
<TextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_prev"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_prev"/>
</FrameLayout>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_play_pause"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_play"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<TextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_next"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_next"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_vol_down"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_vol_down"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_seek_back"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_seek_back"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_seek_forward"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_seek_forward"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_vol_up"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_vol_up"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<CheckBox
style="@style/PlaybackCheckbox"
android:id="@+id/check_shuffle"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_shuffle"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<CheckBox
style="@style/PlaybackCheckbox"
android:id="@+id/check_mute"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_mute"/>
<android.support.v4.widget.Space
android:layout_width="2dp"
android:layout_height="0dp" />
<CheckBox
style="@style/PlaybackCheckbox"
android:id="@+id/check_repeat"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/button_repeat_off"/>
</LinearLayout>
</LinearLayout>
</RelativeLayout>

View File

@ -56,6 +56,22 @@
</android.support.design.widget.TextInputLayout>
<android.support.design.widget.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:layout_marginLeft="24dp">
<EditText
android:id="@+id/password"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:hint="@string/edit_connection_password"
android:inputType="textPassword" />
</android.support.design.widget.TextInputLayout>
<android.support.v4.widget.Space
android:layout_width="0dp"
android:layout_height="8dp"/>

View File

@ -19,6 +19,10 @@
<string name="button_repeat_list">repeat list</string>
<string name="button_repeat_track">repeat song</string>
<string name="button_save">save</string>
<string name="button_settings">settings</string>
<string name="button_close">close</string>
<string name="invalid_password_dialog_title">invalid password</string>
<string name="invalid_password_dialog_message">the server rejected your password.\n\nchange the password in the settings screen.</string>
<string name="status_connecting">connecting</string>
<string name="status_disconnected">disconnected</string>
<string name="status_connected">connected to %1$s</string>
@ -26,6 +30,7 @@
<string name="edit_connection_info">connection info:</string>
<string name="edit_connection_hostname">ip address or hostname</string>
<string name="edit_connection_port">port</string>
<string name="edit_connection_password">password (default empty)</string>
<string name="transport_not_playing">not playing</string>
<string name="search_hint">search</string>
<string name="menu_settings">settings</string>