poniedziałek, 25 kwietnia 2016

[DSP2016] Android prosto z poligonu odc.10 (ustawienia, sockety) + Raspberry Pi

W tym odcinku impreza na biurku na całego! Mamy działającą komunikację po socketach pomiędzy app-ką Android, a ostatnio prezentowanym sprzętowym rozwiązaniem opartym na Raspberry Pi 2 i Windows 10 IoT Core. Zresztą sami zobaczcie:

Aby dość wygodnie korzystało się z takiej komunikacji, w aplikacji Android wprowadziłem obsługę ustawień:  czy wysyłamy dane sterujące na zewnątrz, adres hosta i numer jego portu:

Screenshot_20160424-030350    Screenshot_20160424-030241 

Screenshot_20160424-030451    Screenshot_20160424-030510 

Ustawienia w Android to całkiem dość bogate zagadnienie. Na początek mogę polecić dwa oficjalne linki:

Dodatkowo “na boku” testowo wygenerowałem sobie w Android Studio aktywność z ustawieniami (New –> Activity –> Settings Activity), by zobaczyć co dostajemy w takiej żywej próbce kodu od Google. Przykład niezły, ale zbyt mocno złożony w stosunku do tego, co potrzebowałem, więc funkcjonalność ustawień pisałem ręcznie kierując się powyższymi linkami, natomiast z tej przykładowej implementacji wyciągnąłem tylko kilka drobnych patentów.

Zgodnie z zaleceniami postawiłem na swoją dowolną aktywność SettingsActivity, która gości w sobie specyficzny fragment prezentujący ustawienia u mnie pod nazwą SettingsFragment. Kod aktywności wygląda następująco:

public class SettingsActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

setupActionBar();

getFragmentManager().beginTransaction()
.replace(android.R.id.content, new SettingsFragment())
.commit();
}

private void setupActionBar() {
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
}

@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == android.R.id.home) {
startActivity(new Intent(this, MainActivity.class));
return true;
}
return super.onOptionsItemSelected(item);
}
}

Jak widzimy nie ma ona layoutu w XML. Aby narysował się pasek aplikacji w manifeście nie ustawiłem dla niej skórki AppTheme.NoActionBar, tak jak mamy zrobione to przy innych aktywnościach. Metoda setDisplayHomeAsUpEnabled powoduje pojawienie się strzałki powrotu, którą zresztą jawnie oprogramowałem w metodzie onOptionsItemSelected.

Przejdźmy teraz do SettingsFragment. Dziedziczymy go po klasie PreferenceFragment i dostarczamy mu plik XML o specjalnej strukturze i elementach, będący w pliku res/xml/preferences.xml. Kod fragmentu wygląda tak:

public class SettingsFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener {

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.preferences);

SharedPreferences sharedPreferences = getPreferenceManager().getSharedPreferences();
onSharedPreferenceChanged(sharedPreferences, PreferencesHelper.KEY_PREF_REMOTE_DEVICE_HOST);
onSharedPreferenceChanged(sharedPreferences, PreferencesHelper.KEY_PREF_REMOTE_DEVICE_PORT);
}

@Override
public void onResume() {
super.onResume();
getPreferenceManager().getSharedPreferences().registerOnSharedPreferenceChangeListener(this);
}

@Override
public void onPause() {
getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
super.onPause();
}

@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {

if (key.equals(PreferencesHelper.KEY_PREF_REMOTE_DEVICE_HOST)) {
Preference hostPref = findPreference(key);

String value = sharedPreferences.getString(key, "");

if (TextUtils.isEmpty(value))
hostPref.setSummary(R.string.pref_no_data);
else
hostPref.setSummary(value);
}
else if (key.equals(PreferencesHelper.KEY_PREF_REMOTE_DEVICE_PORT)) {
Preference portPref = findPreference(key);

int value = sharedPreferences.getInt(key, 0);

portPref.setSummary(Integer.toString(value));
}
}
}

Należy się teraz kilka słów wyjaśnienia. Ładowanie we fragmencie interfejsu XML odbywa się w onCreate za pomocą specjalnej metody addPreferencesFromResource. Odczyt ustawień w postaci słownika klucz-wartość możemy dokonać z każdego miejsca aplikacji za pomocą wywołania getPreferenceManager().getSharedPreferences(). Klasa PreferencesHelper to moja klasa trzymająca w sobie stałe typu String do kluczy naszych trzech ustawień (przyda się nam później jeszcze w MusicService). Nasłuchiwanie zmian w ustawieniach aplikacji dokonujemy przez implementację interfejsu SharedPreferences.OnSharedPreferenceChangeListener złożoną z jednej metody onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key). Listenerem jest tutaj cały fragment. Nasłuch zapinamy w metodzie onResume, a odpinamy w onPause.

Po co to wszystko robimy? Co robi dalsza część kodu onCreate po addPreferencesFromResource ? Na te pytania odpowiem, jak zapoznamy się trochę z plikiem XML odpowiedzialnym za narysowanie UI do ustawień. Jego zawartość jest następująca:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">

<SwitchPreference
android:key="pref_use_remote_device"
android:defaultValue="false"
android:title="@string/pref_title_use_remote_device"
android:summary="@string/pref_summary_use_remote_device" />

<EditTextPreference
android:key="pref_remote_device_host"
android:dependency="pref_use_remote_device"
android:defaultValue=""
android:inputType="text"
android:maxLines="1"
android:singleLine="true"
android:title="@string/pref_title_remote_device_host" />

<com.apps.kruszyn.lightorganapp.ui.NumberPickerPreference
android:key="pref_remote_device_port"
android:dependency="pref_use_remote_device"
android:defaultValue="8181"
android:title="@string/pref_title_remote_device_port" />

</PreferenceScreen>

Pliki tego rodzaju składają się z elementów *Preference. Mamy kilka predefiniowanych rodzajów (tekst, lista, switch, checkbox), możemy też pisać własne (np. do liczb, co jeszcze omówimy) czy wywoływać z nich własne aktywności. Każde ustawienie z reguły zawiera atrybuty key (klucz) i title (nazwa ustawienia np. “Host”). Gdy nazwa ustawienia nie jest dość jasna, możemy dodać bardziej precyzyjne objaśnienie przez atrybut summary (u mnie przy switch). Atrybut ten używamy też w ustawianiach innych niż switch czy check do opisania aktualnego stanu (u mnie host i port). Domyślną wartość wyrażamy przez atrybut defaultValue. Domyślne wartości dla ustawień najczęściej ładujemy tylko raz przy pierwszym uruchomieniu aplikacji (chyba że chcemy obsłużyć reset do domyślnych). Robimy to w onCreate każdej aktywności, przez którą może użytkownik wejść do aplikacji. U mnie w MainActivity we wspomnianym miejscu znajduje się linijka:

PreferenceManager.setDefaultValues(this, R.xml.preferences, false);

Drugi parametr false oznacza, żeby ładować domyślne ustawienia jeśli nigdy wcześniej nie były już ładowane. Wartością true spowodowalibyśmy ciągły reset ustawień do wartości domyślnych. Patrząc na dwa pierwsze screeny dostrzegamy, że ustawienia hosta i portu nie są aktywne, gdy switch w pierwszym ustawieniu jest w pozycji off. Osiągamy to całkowicie w sposób deklaratywny za pomocą atrybutu dependency.

Wrócmy teraz do kodu fragmentu i odpowiedzi na postawione ostatnio pytania. Po co nasłuch na zmiany w ustawieniach?  Po to, by odświeżać stany ustawień host i port bezpośrednio po zakończeniu ich edycji (atrybuty summary). Metodą findPreference znajdujemy komponent wizualny dla danego ustawienia. Co robimy w onCreate, jak już wczytamy plik XML?  Ustawiamy aktualne stany ustawień host i port, by na dzień dobry było wiadomo, na czym stoimy.

Przyjrzyjmy się edycji ustawień. O ile do wartości tekstowej dostajemy standardowy EditTextPreference, o tyle w przypadku liczby potrzebujemy napisać sobie sami taki komponent. U mnie jest to klasa NumberPickerPreference o kodzie:

public class NumberPickerPreference extends DialogPreference {

private static final int DEFAULT_VALUE = 0;

private NumberPicker mNumberPicker;
private Integer mCurrentValue;
    public NumberPickerPreference(Context context, AttributeSet attrs) {
super(context, attrs);

setDialogLayoutResource(R.layout.numberpicker_dialog);
setPositiveButtonText(android.R.string.ok);
setNegativeButtonText(android.R.string.cancel);

setDialogIcon(null);
}

@Override
protected void onBindDialogView(View view) {
super.onBindDialogView(view);

mNumberPicker = (NumberPicker)view.findViewById(R.id.numberPicker);
mNumberPicker.setMinValue(0);
mNumberPicker.setMaxValue(65535);

if (mCurrentValue != null)
mNumberPicker.setValue(mCurrentValue);
}

@Override
protected void onDialogClosed(boolean positiveResult) {
if (positiveResult) {
mCurrentValue = mNumberPicker.getValue();
persistInt(mCurrentValue);
}
}

@Override
protected void onSetInitialValue(boolean restorePersistedValue, Object defaultValue) {
if (restorePersistedValue) {
mCurrentValue = this.getPersistedInt(DEFAULT_VALUE);
} else {
mCurrentValue = (Integer) defaultValue;
persistInt(mCurrentValue);
}
}

@Override
protected Object onGetDefaultValue(TypedArray a, int index) {
return a.getInteger(index, DEFAULT_VALUE);
}


@Override
protected Parcelable onSaveInstanceState() {
final Parcelable superState = super.onSaveInstanceState();

if (isPersistent()) {
return superState;
}

final SavedState myState = new SavedState(superState);
myState.value = mNumberPicker.getValue();
return myState;
}

@Override
protected void onRestoreInstanceState(Parcelable state) {

if (state == null || !state.getClass().equals(SavedState.class)) {
super.onRestoreInstanceState(state);
return;
}

SavedState myState = (SavedState) state;
super.onRestoreInstanceState(myState.getSuperState());

mNumberPicker.setValue(myState.value);
}
    private static class SavedState extends BaseSavedState {

int value;

public SavedState(Parcelable superState) {
super(superState);
}

public SavedState(Parcel source) {
super(source);
value = source.readInt();
}

@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(value);
}

public static final Parcelable.Creator<SavedState> CREATOR =
new Parcelable.Creator<SavedState>() {

public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}

public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
}

i layoucie numberpicker_dialog.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_horizontal">

<NumberPicker
android:id="@+id/numberPicker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

</LinearLayout>

Do zrozumienia tej implementacji polecam link http://developer.android.com/guide/topics/ui/settings.html#Custom. Przy praktycznej finalizacji posiłkowałem się też całkiem dobrymi przykładami na stronach https://www.staldal.nu/tech/2015/05/16/custom-preference-for-android/ i http://codetheory.in/saving-user-settings-with-android-preferences/ (w tym drugim przy okazji dobre podsumowanie wszystkich zagadnień związanych z ustawieniami).

Teraz pora na sockety w serwisie  muzycznym w tle, powiązane z ustawieniami aplikacji. Zasadnicze zmiany w MusicService.java kształtują się w następujący sposób:

private Socket mCommandSocket;
private OutputStream mOutputStream;

private PreferenceListener mPrefListener;
@Override
public int onStartCommand(Intent startIntent, int flags, int startId) {

Context context = getApplicationContext();
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);

boolean useRemoteDevice = preferences.getBoolean(PreferencesHelper.KEY_PREF_USE_REMOTE_DEVICE, false);

if (useRemoteDevice)
createNewSocket(preferences);

mPrefListener = new PreferenceListener();
preferences.registerOnSharedPreferenceChangeListener(mPrefListener);

}
@Override
public void onDestroy() {
releaseSocket();


}
@Override
public void onLightOrganDataUpdated(LightOrganData data) {


byte bassValue = (byte)Math.round(255 * data.bassLevel);
byte midValue = (byte)Math.round(255 * data.midLevel);
byte trebleValue = (byte)Math.round(255 * data.trebleLevel);

byte[] bytes = new byte[3];
bytes[0] = bassValue;
bytes[1] = midValue;
bytes[2] = trebleValue;

sendCommand(bytes);
}
private void sendCommand(byte[] bytes) {
try {

if (mOutputStream != null) {
mOutputStream.write(bytes);
mOutputStream.flush();
}

} catch (IOException e) {
e.printStackTrace();
}
}

private void createNewSocket(SharedPreferences preferences) {

String host = preferences.getString(PreferencesHelper.KEY_PREF_REMOTE_DEVICE_HOST, "");
int port = preferences.getInt(PreferencesHelper.KEY_PREF_REMOTE_DEVICE_PORT, 0);

if (!TextUtils.isEmpty(host) && port > 0) {
Runnable connect = new ConnectSocket(host,port);
new Thread(connect).start();
}
}

private void releaseSocket() {
try {
byte[] bytes = new byte[3];
bytes[0] = 13;
bytes[1] = 13;
bytes[2] = 13;

sendCommand(bytes);

if (mOutputStream != null)
mOutputStream.close();

if (mCommandSocket != null)
mCommandSocket.close();

} catch (IOException e) {
e.printStackTrace();
}
mOutputStream = null;
mCommandSocket = null;
}

private void onPreferenceChanged(SharedPreferences sharedPreferences, String key) {

try {

boolean useRemoteDevice = sharedPreferences.getBoolean(PreferencesHelper.KEY_PREF_USE_REMOTE_DEVICE, false);

if (key.equals(PreferencesHelper.KEY_PREF_USE_REMOTE_DEVICE)) {
if (mCommandSocket != null && !useRemoteDevice) {
releaseSocket();
} else if (mCommandSocket == null && useRemoteDevice) {
createNewSocket(sharedPreferences);
}
} else if (key.equals(PreferencesHelper.KEY_PREF_REMOTE_DEVICE_HOST) || key.equals(PreferencesHelper.KEY_PREF_REMOTE_DEVICE_PORT)) {
if (useRemoteDevice) {
releaseSocket();
createNewSocket(sharedPreferences);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public class ConnectSocket implements Runnable {

private String mHost;
private int mPort;

public ConnectSocket(String host, int port) {
mHost = host;
mPort = port;
}

@Override
public void run() {

try {

mCommandSocket = new Socket(mHost, mPort);
mOutputStream = mCommandSocket.getOutputStream();

} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private class PreferenceListener implements SharedPreferences.OnSharedPreferenceChangeListener {

public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
onPreferenceChanged(sharedPreferences, key);
}
}

Komunikacja po socketach jest dość podstawowa. Tworzę socket i zapisuję trójki bajtów charakteryzujące muzykę w danej chwili do strumienia. Podobnie jak we fragmencie nasłuchuje zmian w ustawieniach aplikacji za pomocą interfejsu SharedPreferences.OnSharedPreferenceChangeListener (tutaj osobna klasa listenera). W zależności od zmian likwiduję socket,  tworzę go albo likwiduję i tworzę z innymi parametrami.  Kilka linków, które nieco pomogły ukształtować mi wizję:

To tyle na dziś. Następnym razem zupełna zmiana klimatów.

Brak komentarzy: